From 3ece6770e1d64c1f40d9a31004d34a46646faf05 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:42:49 +0000 Subject: [PATCH 01/38] chore(deps): update actions/checkout action to v6 --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c2fd467a..5c6de3d2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Pages uses: actions/configure-pages@v5 From 8c722b0a182056bba843a44d9fda2ce666f00555 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:42:53 +0000 Subject: [PATCH 02/38] chore(deps): update actions/upload-pages-artifact action to v4 --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c2fd467a..fa92ff62 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -28,7 +28,7 @@ jobs: uses: actions/configure-pages@v5 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: site From 8e6cbcbc2abc9629629581ddff05d955db353796 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 17 Feb 2026 17:08:02 +0700 Subject: [PATCH 03/38] feat: YouTube customizable bitrate, improved title matching, SpotubeDL engine fallback - Add configurable YouTube Opus (96-256kbps) and MP3 (96-320kbps) bitrates - Improve title matching with loose normalization for symbol-heavy tracks - Add SpotubeDL engine v2 fallback for MP3 requests - Improve filename sanitization in track metadata screen - Bump version to 3.6.9+82 --- CHANGELOG.md | 24 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 +- go_backend/exports.go | 2 +- go_backend/lyrics.go | 12 +- go_backend/qobuz.go | 20 ++ go_backend/tidal.go | 20 ++ go_backend/title_match_utils.go | 43 ++++ go_backend/title_match_utils_test.go | 34 +++ go_backend/youtube.go | 218 ++++++++++++---- go_backend/youtube_quality_test.go | 41 +++ lib/constants/app_info.dart | 4 +- lib/l10n/app_localizations.dart | 36 +++ lib/l10n/app_localizations_de.dart | 24 ++ lib/l10n/app_localizations_en.dart | 24 ++ lib/l10n/app_localizations_es.dart | 24 ++ lib/l10n/app_localizations_fr.dart | 24 ++ lib/l10n/app_localizations_hi.dart | 24 ++ lib/l10n/app_localizations_id.dart | 24 ++ lib/l10n/app_localizations_ja.dart | 24 ++ lib/l10n/app_localizations_ko.dart | 24 ++ lib/l10n/app_localizations_nl.dart | 24 ++ lib/l10n/app_localizations_pt.dart | 24 ++ lib/l10n/app_localizations_ru.dart | 24 ++ lib/l10n/app_localizations_tr.dart | 24 ++ lib/l10n/app_localizations_zh.dart | 24 ++ lib/l10n/arb/app_en.arb | 31 +++ lib/l10n/arb/app_id.arb | 75 +++++- lib/models/settings.dart | 26 +- lib/models/settings.g.dart | 6 +- lib/providers/download_queue_provider.dart | 27 +- lib/providers/settings_provider.dart | 63 ++++- .../settings/download_settings_page.dart | 131 +++++++++- lib/screens/track_metadata_screen.dart | 17 +- lib/services/cover_cache_manager.dart | 93 ++++++- lib/widgets/download_service_picker.dart | 240 +++++++++++++----- pubspec.yaml | 2 +- 36 files changed, 1317 insertions(+), 166 deletions(-) create mode 100644 go_backend/title_match_utils.go create mode 100644 go_backend/title_match_utils_test.go create mode 100644 go_backend/youtube_quality_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 78eccb93..8490b61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [3.6.9] - 2026-02-17 + +### Added + +- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only + - Opus: 128 / 256 kbps + - MP3: 128 / 256 / 320 kbps +- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior +- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels + +### Fixed + +- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state + - Prevents stale/orphaned cache files from keeping the same storage usage after clear +- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs + +### Changed + +- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs +- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser +- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off) + +--- + ## [3.6.8] - 2026-02-14 ### Added diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c79c58a3..5f349f7f 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,9 +1,5 @@ - - - + diff --git a/go_backend/exports.go b/go_backend/exports.go index 3adfcb9f..9da92ca4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1521,7 +1521,7 @@ func errorResponse(msg string) (string, error) { // ==================== YOUTUBE PROVIDER (LOSSY ONLY) ==================== // DownloadFromYouTube downloads a track from YouTube via Cobalt API -// This is a lossy-only provider (Opus 256kbps or MP3 320kbps) +// This is a lossy-only provider (Opus/MP3 with configurable bitrate) // It does NOT participate in the lossless fallback chain func DownloadFromYouTube(requestJSON string) (string, error) { var req DownloadRequest diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 576772f7..86c9087d 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -790,8 +790,18 @@ func simplifyTrackName(name string) string { re := regexp.MustCompile("(?i)" + pattern) result = re.ReplaceAllString(result, "") } + result = strings.TrimSpace(result) + if result == "" { + return result + } - return strings.TrimSpace(result) + // Add a loose fallback form for provider queries where punctuation + // and separators differ (e.g. "/" vs "_" vs spaces). + if loose := normalizeLooseTitle(result); loose != "" { + return loose + } + + return result } func normalizeArtistName(name string) string { diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 23914f5b..f8f0fcbc 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -174,6 +174,26 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return true } + looseExpected := normalizeLooseTitle(normExpected) + looseFound := normalizeLooseTitle(normFound) + if looseExpected != "" && looseFound != "" { + if looseExpected == looseFound { + return true + } + if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) { + return true + } + } + + // Some tracks are symbol/emoji-heavy and providers can return textual + // aliases. If artist/duration already matched upstream, avoid false rejects. + if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && + strings.TrimSpace(expectedTitle) != "" && + strings.TrimSpace(foundTitle) != "" { + GoLog("[Qobuz] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + expectedLatin := qobuzIsLatinScript(expectedTitle) foundLatin := qobuzIsLatinScript(foundTitle) if expectedLatin != foundLatin { diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 8e52aa8b..bc0ca7ba 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1289,6 +1289,26 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } + looseExpected := normalizeLooseTitle(normExpected) + looseFound := normalizeLooseTitle(normFound) + if looseExpected != "" && looseFound != "" { + if looseExpected == looseFound { + return true + } + if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) { + return true + } + } + + // Some tracks are symbol/emoji-heavy and providers can return textual + // aliases. If artist/duration already matched upstream, avoid false rejects. + if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && + strings.TrimSpace(expectedTitle) != "" && + strings.TrimSpace(foundTitle) != "" { + GoLog("[Tidal] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + expectedLatin := isLatinScript(expectedTitle) foundLatin := isLatinScript(foundTitle) if expectedLatin != foundLatin { diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go new file mode 100644 index 00000000..a7bb186b --- /dev/null +++ b/go_backend/title_match_utils.go @@ -0,0 +1,43 @@ +package gobackend + +import ( + "strings" + "unicode" +) + +// normalizeLooseTitle collapses separators/punctuation so titles like +// "Doctor / Cops" and "Doctor _ Cops" can still match. +func normalizeLooseTitle(title string) string { + trimmed := strings.TrimSpace(strings.ToLower(title)) + if trimmed == "" { + return "" + } + + var b strings.Builder + b.Grow(len(trimmed)) + + for _, r := range trimmed { + switch { + case unicode.IsLetter(r), unicode.IsNumber(r): + b.WriteRune(r) + case unicode.IsSpace(r): + b.WriteByte(' ') + // Treat common separators as spaces. + case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': + b.WriteByte(' ') + default: + // Drop other punctuation/symbols (including emoji) for loose matching. + } + } + + return strings.Join(strings.Fields(b.String()), " ") +} + +func hasAlphaNumericRunes(value string) bool { + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + return true + } + } + return false +} diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go new file mode 100644 index 00000000..b9064c91 --- /dev/null +++ b/go_backend/title_match_utils_test.go @@ -0,0 +1,34 @@ +package gobackend + +import "testing" + +func TestNormalizeLooseTitle_Separators(t *testing.T) { + got := normalizeLooseTitle("Doctor / Cops") + if got != "doctor cops" { + t.Fatalf("expected doctor cops, got %q", got) + } + + got = normalizeLooseTitle("Doctor _ Cops") + if got != "doctor cops" { + t.Fatalf("expected doctor cops, got %q", got) + } +} + +func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) { + got := normalizeLooseTitle("Music Of The Spheres 🌎✨") + if got != "music of the spheres" { + t.Fatalf("expected music of the spheres, got %q", got) + } +} + +func TestTitlesMatch_SeparatorVariants(t *testing.T) { + if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { + t.Fatal("expected tidal titlesMatch to accept / vs _ variant") + } +} + +func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) { + if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") { + t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant") + } +} diff --git a/go_backend/youtube.go b/go_backend/youtube.go index 0309321b..e5ff2c29 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -20,6 +21,8 @@ type YouTubeDownloader struct { mu sync.Mutex } +const spotubeBaseURL = "https://spotubedl.com" + var ( globalYouTubeDownloader *YouTubeDownloader youtubeDownloaderOnce sync.Once @@ -29,9 +32,17 @@ type YouTubeQuality string const ( YouTubeQualityOpus256 YouTubeQuality = "opus_256" + YouTubeQualityOpus128 YouTubeQuality = "opus_128" + YouTubeQualityMP3128 YouTubeQuality = "mp3_128" + YouTubeQualityMP3256 YouTubeQuality = "mp3_256" YouTubeQualityMP3320 YouTubeQuality = "mp3_320" ) +var ( + youtubeOpusSupportedBitrates = []int{128, 256} + youtubeMp3SupportedBitrates = []int{128, 256, 320} +) + type CobaltRequest struct { URL string `json:"url"` AudioBitrate string `json:"audioBitrate,omitempty"` @@ -79,6 +90,77 @@ func NewYouTubeDownloader() *YouTubeDownloader { return globalYouTubeDownloader } +func extractBitrateFromQuality(raw string, defaultBitrate int) int { + parts := strings.FieldsFunc(raw, func(r rune) bool { + return (r < '0' || r > '9') + }) + for i := len(parts) - 1; i >= 0; i-- { + part := parts[i] + if part == "" { + continue + } + if parsed, err := strconv.Atoi(part); err == nil { + return parsed + } + } + return defaultBitrate +} + +func nearestSupportedBitrate(value int, supported []int) int { + nearest := supported[0] + nearestDistance := absInt(value - nearest) + + for _, option := range supported[1:] { + distance := absInt(value - option) + // On tie prefer higher quality. + if distance < nearestDistance || (distance == nearestDistance && option > nearest) { + nearest = option + nearestDistance = distance + } + } + + return nearest +} + +func absInt(value int) int { + if value < 0 { + return -value + } + return value +} + +func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) { + normalizedRaw := strings.ToLower(strings.TrimSpace(raw)) + + if strings.HasPrefix(normalizedRaw, "opus") { + parsed := extractBitrateFromQuality(normalizedRaw, 256) + finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates) + return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate)) + } + + if strings.HasPrefix(normalizedRaw, "mp3") { + parsed := extractBitrateFromQuality(normalizedRaw, 320) + finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates) + return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate)) + } + + // Backward compatibility for legacy symbolic values. + switch normalizedRaw { + case "opus_256", "opus256", "opus": + return "opus", 256, YouTubeQualityOpus256 + case "opus_128", "opus128": + return "opus", 128, YouTubeQualityOpus128 + case "mp3_320", "mp3320", "mp3", "": + return "mp3", 320, YouTubeQualityMP3320 + case "mp3_256", "mp3256": + return "mp3", 256, YouTubeQualityMP3256 + case "mp3_128", "mp3128": + return "mp3", 128, YouTubeQualityMP3128 + default: + return "mp3", 320, YouTubeQualityMP3320 + } +} + // SearchYouTube returns a YouTube Music search URL for the given track func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { query := fmt.Sprintf("%s %s", artistName, trackName) @@ -95,22 +177,11 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua y.mu.Lock() defer y.mu.Unlock() - var audioFormat string - var audioBitrate string - - switch quality { - case YouTubeQualityOpus256: - audioFormat = "opus" - audioBitrate = "256" - case YouTubeQualityMP3320: - audioFormat = "mp3" - audioBitrate = "320" - default: - audioFormat = "mp3" - audioBitrate = "320" - } + audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality)) + audioBitrate := strconv.Itoa(bitrate) // Try SpotubeDL first (primary) + var spotubeErr error videoID, extractErr := ExtractYouTubeVideoID(youtubeURL) if extractErr == nil { GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n", @@ -120,6 +191,7 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua if err == nil { return resp, nil } + spotubeErr = err GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err) } else { GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr) @@ -132,6 +204,9 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate) if err != nil { + if spotubeErr != nil { + return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err) + } return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err) } @@ -201,11 +276,34 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr } // requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). +// Note: engine v2 currently serves MP3-oriented outputs, so we only use v2 for MP3 requests. func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) { - apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s", - videoID, audioFormat, audioBitrate) + engines := []string{"v1"} + if strings.EqualFold(audioFormat, "mp3") { + engines = append(engines, "v2") + } + var lastErr error - GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL) + for _, engine := range engines { + resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine) + if err == nil { + return resp, nil + } + lastErr = err + GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err) + } + + if lastErr == nil { + lastErr = fmt.Errorf("no SpotubeDL engine available") + } + return nil, lastErr +} + +func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) { + apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s", + spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate)) + + GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -225,27 +323,60 @@ func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate return nil, fmt.Errorf("failed to read response: %w", err) } - GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode) + GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode) if resp.StatusCode != 200 { - return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body)) } var result struct { - URL string `json:"url"` + URL string `json:"url"` + Status string `json:"status"` + Error string `json:"error"` + Message string `json:"message"` + Filename string `json:"filename"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to parse spotubedl response: %w", err) } - if result.URL == "" { - return nil, fmt.Errorf("no download URL from spotubedl") + downloadURL := strings.TrimSpace(result.URL) + if downloadURL == "" { + if result.Error != "" { + return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error) + } + if result.Message != "" { + return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message) + } + return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine) } - GoLog("[YouTube] Got download URL from SpotubeDL\n") + if strings.HasPrefix(downloadURL, "/") { + downloadURL = spotubeBaseURL + downloadURL + } + + if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") { + return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL) + } + + filename := strings.TrimSpace(result.Filename) + if filename == "" { + if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil { + if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" { + if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil { + filename = decodedFilename + } else { + filename = queryFilename + } + } + } + } + + GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine) return &CobaltResponse{ - Status: "tunnel", - URL: result.URL, + Status: "tunnel", + URL: downloadURL, + Filename: filename, }, nil } @@ -411,15 +542,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { downloader := NewYouTubeDownloader() - var quality YouTubeQuality - switch strings.ToLower(req.Quality) { - case "opus_256", "opus256", "opus": - quality = YouTubeQualityOpus256 - case "mp3_320", "mp3320", "mp3": - quality = YouTubeQualityMP3320 - default: - quality = YouTubeQualityMP3320 // Default to MP3 320kbps - } + format, bitrate, quality := parseYouTubeQualityInput(req.Quality) // URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC var youtubeURL string @@ -480,18 +603,23 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - var ext string - var format string - var bitrate int - switch quality { - case YouTubeQualityOpus256: + ext := ".mp3" + if format == "opus" { ext = ".opus" - format = "opus" - bitrate = 256 - case YouTubeQualityMP3320: - ext = ".mp3" - format = "mp3" - bitrate = 320 + } + + // Some SpotubeDL engines may return a different output container than requested. + // Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension. + if cobaltResp != nil && cobaltResp.Filename != "" { + lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename)) + switch { + case strings.HasSuffix(lowerName, ".mp3"): + ext = ".mp3" + format = "mp3" + case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"): + ext = ".opus" + format = "opus" + } } filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go new file mode 100644 index 00000000..cb77e5bb --- /dev/null +++ b/go_backend/youtube_quality_test.go @@ -0,0 +1,41 @@ +package gobackend + +import "testing" + +func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) { + format, bitrate, normalized := parseYouTubeQualityInput("opus_160") + if format != "opus" { + t.Fatalf("expected opus format, got %s", format) + } + if bitrate != 128 { + t.Fatalf("expected 128 bitrate, got %d", bitrate) + } + if normalized != YouTubeQualityOpus128 { + t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized) + } +} + +func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) { + format, bitrate, normalized := parseYouTubeQualityInput("mp3_192") + if format != "mp3" { + t.Fatalf("expected mp3 format, got %s", format) + } + if bitrate != 256 { + t.Fatalf("expected 256 bitrate, got %d", bitrate) + } + if normalized != YouTubeQualityMP3256 { + t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized) + } +} + +func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { + _, opusBitrate, _ := parseYouTubeQualityInput("opus_999") + if opusBitrate != 256 { + t.Fatalf("expected opus normalization to 256, got %d", opusBitrate) + } + + _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1") + if mp3Bitrate != 128 { + t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate) + } +} diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 7a8d737e..9a2e08a7 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.6.7'; - static const String buildNumber = '81'; + static const String version = '3.6.9'; + static const String buildNumber = '82'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7013a130..40a8f4f7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3508,6 +3508,42 @@ abstract class AppLocalizations { /// **'YouTube provides lossy audio only. Not part of lossless fallback.'** String get youtubeQualityNote; + /// Title for YouTube Opus bitrate setting + /// + /// In en, this message translates to: + /// **'YouTube Opus Bitrate'** + String get youtubeOpusBitrateTitle; + + /// Title for YouTube MP3 bitrate setting + /// + /// In en, this message translates to: + /// **'YouTube MP3 Bitrate'** + String get youtubeMp3BitrateTitle; + + /// Subtitle showing current bitrate and valid range + /// + /// In en, this message translates to: + /// **'{bitrate}kbps ({min}-{max})'** + String youtubeBitrateSubtitle(int bitrate, int min, int max); + + /// Helper text for manual YouTube bitrate input + /// + /// In en, this message translates to: + /// **'Enter custom bitrate ({min}-{max} kbps)'** + String youtubeBitrateInputHelp(int min, int max); + + /// Label for YouTube bitrate input field + /// + /// In en, this message translates to: + /// **'Bitrate (kbps)'** + String get youtubeBitrateFieldLabel; + + /// Validation error for invalid YouTube bitrate input + /// + /// In en, this message translates to: + /// **'Bitrate must be between {min} and {max} kbps'** + String youtubeBitrateValidationError(int min, int max); + /// Setting - show quality picker /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a89201ef..d7a5349b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1944,6 +1944,30 @@ class AppLocalizationsDe extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2f3eefea..8225f5c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1923,6 +1923,30 @@ class AppLocalizationsEn extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 75613f96..f854b116 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1923,6 +1923,30 @@ class AppLocalizationsEs extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4e381dee..566fdcc1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1929,6 +1929,30 @@ class AppLocalizationsFr extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 3afb4c16..44e45c90 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1923,6 +1923,30 @@ class AppLocalizationsHi extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 16fde0a6..f8b81ecc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1935,6 +1935,30 @@ class AppLocalizationsId extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube'; + + @override + String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Masukkan bitrate manual ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate harus antara $min dan $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 370018ad..8284e093 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1911,6 +1911,30 @@ class AppLocalizationsJa extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e97ac06c..a80520d2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1922,6 +1922,30 @@ class AppLocalizationsKo extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b0bd7b60..151a8805 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1923,6 +1923,30 @@ class AppLocalizationsNl extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 3a946c0a..89eaf853 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1923,6 +1923,30 @@ class AppLocalizationsPt extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 306ad760..d38edcc9 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1963,6 +1963,30 @@ class AppLocalizationsRu extends AppLocalizations { String get youtubeQualityNote => 'YouTube обеспечивает только звук с потерями(Lossy).'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index fc66ca2b..1706ce88 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1938,6 +1938,30 @@ class AppLocalizationsTr extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e1d656eb..04d3eba1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1923,6 +1923,30 @@ class AppLocalizationsZh extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String youtubeBitrateSubtitle(int bitrate, int min, int max) { + return '${bitrate}kbps ($min-$max)'; + } + + @override + String youtubeBitrateInputHelp(int min, int max) { + return 'Enter custom bitrate ($min-$max kbps)'; + } + + @override + String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; + + @override + String youtubeBitrateValidationError(int min, int max) { + return 'Bitrate must be between $min and $max kbps'; + } + @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2ff691cb..148fbd13 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1420,6 +1420,37 @@ "@qualityNote": {"description": "Note about quality availability"}, "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", "@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"}, + "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", + "@youtubeOpusBitrateTitle": {"description": "Title for YouTube Opus bitrate setting"}, + "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", + "@youtubeMp3BitrateTitle": {"description": "Title for YouTube MP3 bitrate setting"}, + "youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})", + "@youtubeBitrateSubtitle": { + "description": "Subtitle showing current bitrate and valid range", + "placeholders": { + "bitrate": {"type": "int"}, + "min": {"type": "int"}, + "max": {"type": "int"} + } + }, + "youtubeBitrateInputHelp": "Enter custom bitrate ({min}-{max} kbps)", + "@youtubeBitrateInputHelp": { + "description": "Helper text for manual YouTube bitrate input", + "placeholders": { + "min": {"type": "int"}, + "max": {"type": "int"} + } + }, + "youtubeBitrateFieldLabel": "Bitrate (kbps)", + "@youtubeBitrateFieldLabel": {"description": "Label for YouTube bitrate input field"}, + "youtubeBitrateValidationError": "Bitrate must be between {min} and {max} kbps", + "@youtubeBitrateValidationError": { + "description": "Validation error for invalid YouTube bitrate input", + "placeholders": { + "min": {"type": "int"}, + "max": {"type": "int"} + } + }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 72f43405..16a9f483 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2498,18 +2498,69 @@ "@lossyFormatOpusSubtitle": { "description": "Opus format description" }, - "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", - "@qualityNote": { - "description": "Note about quality availability" - }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "downloadAskBeforeDownload": "Tanya Sebelum Unduh", - "@downloadAskBeforeDownload": { - "description": "Setting - show quality picker" - }, + "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", + "@qualityNote": { + "description": "Note about quality availability" + }, + "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", + "@youtubeQualityNote": { + "description": "Note for YouTube service explaining lossy-only quality" + }, + "youtubeOpusBitrateTitle": "Bitrate Opus YouTube", + "@youtubeOpusBitrateTitle": { + "description": "Title for YouTube Opus bitrate setting" + }, + "youtubeMp3BitrateTitle": "Bitrate MP3 YouTube", + "@youtubeMp3BitrateTitle": { + "description": "Title for YouTube MP3 bitrate setting" + }, + "youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})", + "@youtubeBitrateSubtitle": { + "description": "Subtitle showing current bitrate and valid range", + "placeholders": { + "bitrate": { + "type": "int" + }, + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "youtubeBitrateInputHelp": "Masukkan bitrate manual ({min}-{max} kbps)", + "@youtubeBitrateInputHelp": { + "description": "Helper text for manual YouTube bitrate input", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "youtubeBitrateFieldLabel": "Bitrate (kbps)", + "@youtubeBitrateFieldLabel": { + "description": "Label for YouTube bitrate input field" + }, + "youtubeBitrateValidationError": "Bitrate harus antara {min} dan {max} kbps", + "@youtubeBitrateValidationError": { + "description": "Validation error for invalid YouTube bitrate input", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadAskBeforeDownload": "Tanya Sebelum Unduh", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, "downloadDirectory": "Direktori Unduhan", "@downloadDirectory": { "description": "Setting - download folder" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index ec889913..dfdd3565 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -39,6 +39,10 @@ class AppSettings { final String lyricsMode; final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' + final int + youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps) + final int + youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool @@ -103,6 +107,8 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', + this.youtubeOpusBitrate = 256, + this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', @@ -113,10 +119,16 @@ class AppSettings { // Tutorial default this.hasCompletedTutorial = false, // Lyrics providers default order - this.lyricsProviders = const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], + this.lyricsProviders = const [ + 'lrclib', + 'musixmatch', + 'netease', + 'apple_music', + 'qqmusic', + ], this.lyricsIncludeTranslationNetease = false, this.lyricsIncludeRomanizationNetease = false, - this.lyricsMultiPersonWordByWord = true, + this.lyricsMultiPersonWordByWord = false, this.musixmatchLanguage = '', }); @@ -156,6 +168,8 @@ class AppSettings { String? locale, String? lyricsMode, String? tidalHighFormat, + int? youtubeOpusBitrate, + int? youtubeMp3Bitrate, bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, @@ -215,6 +229,8 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, + youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, + youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, @@ -229,9 +245,11 @@ class AppSettings { // Lyrics providers lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: - lyricsIncludeTranslationNetease ?? this.lyricsIncludeTranslationNetease, + lyricsIncludeTranslationNetease ?? + this.lyricsIncludeTranslationNetease, lyricsIncludeRomanizationNetease: - lyricsIncludeRomanizationNetease ?? this.lyricsIncludeRomanizationNetease, + lyricsIncludeRomanizationNetease ?? + this.lyricsIncludeRomanizationNetease, lyricsMultiPersonWordByWord: lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord, musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2002ffd1..2fa2870d 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -44,6 +44,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', + youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, + youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, @@ -63,7 +65,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( lyricsIncludeRomanizationNetease: json['lyricsIncludeRomanizationNetease'] as bool? ?? false, lyricsMultiPersonWordByWord: - json['lyricsMultiPersonWordByWord'] as bool? ?? true, + json['lyricsMultiPersonWordByWord'] as bool? ?? false, musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', ); @@ -105,6 +107,8 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, + 'youtubeOpusBitrate': instance.youtubeOpusBitrate, + 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 35871a09..4da9e3e5 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2771,7 +2771,29 @@ class DownloadQueueNotifier extends Notifier { settings, ); - final quality = item.qualityOverride ?? state.audioQuality; + var quality = item.qualityOverride ?? state.audioQuality; + if (item.service.toLowerCase() == 'youtube') { + final normalized = quality.toLowerCase(); + final isYoutubeQuality = + normalized.startsWith('mp3_') || normalized.startsWith('opus_'); + if (!isYoutubeQuality) { + final mp3Bitrate = (() { + const supported = [128, 256, 320]; + var nearest = supported.first; + var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs(); + for (final option in supported.skip(1)) { + final distance = (settings.youtubeMp3Bitrate - option).abs(); + if (distance < nearestDistance || + (distance == nearestDistance && option > nearest)) { + nearest = option; + nearestDistance = distance; + } + } + return nearest; + })(); + quality = 'mp3_$mp3Bitrate'; + } + } final isSafMode = _isSafMode(settings); final relativeOutputDir = isSafMode ? await _buildRelativeOutputDir( @@ -3032,8 +3054,7 @@ class DownloadQueueNotifier extends Notifier { outputDir: outputDir, filenameFormat: state.filenameFormat, quality: quality, - // Keep prior behavior: non-YouTube paths were implicitly true. - embedLyrics: isYouTube ? settings.embedLyrics : true, + embedLyrics: settings.embedLyrics, embedMaxQualityCover: settings.maxQualityCover, trackNumber: normalizedTrackNumber, discNumber: normalizedDiscNumber, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 9e469cd2..1011e549 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -13,6 +13,9 @@ const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { + static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; + final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); bool _isSavingSettings = false; @@ -32,6 +35,7 @@ class SettingsNotifier extends Notifier { state = AppSettings.fromJson(jsonDecode(json)); await _runMigrations(prefs); + await _normalizeYouTubeBitratesIfNeeded(); } await _loadSpotifyClientSecret(prefs); @@ -107,6 +111,49 @@ class SettingsNotifier extends Notifier { } } + int _nearestSupportedBitrate(int value, List supported) { + var nearest = supported.first; + var nearestDistance = (value - nearest).abs(); + + for (final option in supported.skip(1)) { + final distance = (value - option).abs(); + // On tie, prefer higher quality bitrate. + if (distance < nearestDistance || + (distance == nearestDistance && option > nearest)) { + nearest = option; + nearestDistance = distance; + } + } + + return nearest; + } + + int _normalizeYouTubeOpusBitrate(int bitrate) { + return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates); + } + + int _normalizeYouTubeMp3Bitrate(int bitrate) { + return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates); + } + + Future _normalizeYouTubeBitratesIfNeeded() async { + final normalizedOpus = _normalizeYouTubeOpusBitrate( + state.youtubeOpusBitrate, + ); + final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate); + + if (normalizedOpus == state.youtubeOpusBitrate && + normalizedMp3 == state.youtubeMp3Bitrate) { + return; + } + + state = state.copyWith( + youtubeOpusBitrate: normalizedOpus, + youtubeMp3Bitrate: normalizedMp3, + ); + await _saveSettings(); + } + Future _loadSpotifyClientSecret(SharedPreferences prefs) async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, @@ -230,7 +277,9 @@ class SettingsNotifier extends Notifier { } void setMusixmatchLanguage(String languageCode) { - state = state.copyWith(musixmatchLanguage: languageCode.trim().toLowerCase()); + state = state.copyWith( + musixmatchLanguage: languageCode.trim().toLowerCase(), + ); _saveSettings(); _syncLyricsSettingsToBackend(); } @@ -390,6 +439,18 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setYoutubeOpusBitrate(int bitrate) { + final normalized = _normalizeYouTubeOpusBitrate(bitrate); + state = state.copyWith(youtubeOpusBitrate: normalized); + _saveSettings(); + } + + void setYoutubeMp3Bitrate(int bitrate) { + final normalized = _normalizeYouTubeMp3Bitrate(bitrate); + state = state.copyWith(youtubeMp3Bitrate: normalized); + _saveSettings(); + } + void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index a6a7b032..4efc7e55 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -261,6 +261,33 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ], + SettingsItem( + title: context.l10n.youtubeOpusBitrateTitle, + subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)', + onTap: () => _showYoutubeBitratePicker( + context: context, + title: context.l10n.youtubeOpusBitrateTitle, + currentValue: settings.youtubeOpusBitrate, + options: const [128, 256], + onSave: (value) => ref + .read(settingsProvider.notifier) + .setYoutubeOpusBitrate(value), + ), + ), + SettingsItem( + title: context.l10n.youtubeMp3BitrateTitle, + subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)', + onTap: () => _showYoutubeBitratePicker( + context: context, + title: context.l10n.youtubeMp3BitrateTitle, + currentValue: settings.youtubeMp3Bitrate, + options: const [128, 256, 320], + onSave: (value) => ref + .read(settingsProvider.notifier) + .setYoutubeMp3Bitrate(value), + ), + showDivider: false, + ), ], ), ), @@ -271,20 +298,35 @@ class _DownloadSettingsPageState extends ConsumerState { SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsSwitchItem( + icon: Icons.subtitles_outlined, + title: context.l10n.optionsEmbedLyrics, + subtitle: context.l10n.optionsEmbedLyricsSubtitle, + value: settings.embedLyrics, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setEmbedLyrics(value), + ), SettingsItem( icon: Icons.lyrics_outlined, title: context.l10n.lyricsMode, - subtitle: _getLyricsModeLabel(context, settings.lyricsMode), - onTap: () => _showLyricsModePicker( - context, - ref, - settings.lyricsMode, - ), + subtitle: settings.embedLyrics + ? _getLyricsModeLabel(context, settings.lyricsMode) + : context.l10n.extensionsDisabled, + onTap: settings.embedLyrics + ? () => _showLyricsModePicker( + context, + ref, + settings.lyricsMode, + ) + : null, ), SettingsItem( icon: Icons.source_outlined, title: 'Lyrics Providers', - subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders), + subtitle: _getLyricsProvidersSubtitle( + settings.lyricsProviders, + ), onTap: () => Navigator.push( context, MaterialPageRoute( @@ -1250,9 +1292,7 @@ class _DownloadSettingsPageState extends ConsumerState { String _getLyricsProvidersSubtitle(List providers) { if (providers.isEmpty) return 'None enabled'; - return providers - .map((p) => _providerDisplayNames[p] ?? p) - .join(' > '); + return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > '); } String _normalizeMusixmatchLanguage(String value) { @@ -1260,6 +1300,67 @@ class _DownloadSettingsPageState extends ConsumerState { return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); } + void _showYoutubeBitratePicker({ + required BuildContext context, + required String title, + required int currentValue, + required List options, + required void Function(int value) onSave, + }) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + for (final bitrate in options) + ListTile( + title: Text('$bitrate kbps'), + trailing: bitrate == currentValue + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + onSave(bitrate); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + void _showMusixmatchLanguagePicker( BuildContext context, WidgetRef ref, @@ -1288,9 +1389,9 @@ class _DownloadSettingsPageState extends ConsumerState { children: [ Text( 'Musixmatch Language', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( @@ -1319,7 +1420,9 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), TextButton( onPressed: () { - ref.read(settingsProvider.notifier).setMusixmatchLanguage(''); + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(''); Navigator.pop(context); }, child: const Text('Auto'), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index ca4f22b0..16d1d441 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -76,6 +76,9 @@ class _TrackMetadataScreenState extends ConsumerState { ); static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*'); static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$'); + static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]'); + static final RegExp _multiUnderscore = RegExp(r'_+'); + static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$'); static const List _months = [ 'Jan', 'Feb', @@ -1722,9 +1725,19 @@ class _TrackMetadataScreenState extends ConsumerState { } } + String _sanitizeFileNameSegment(String value) { + var sanitized = value.replaceAll(_invalidFileNameChars, '_').trim(); + sanitized = sanitized.replaceAll(_leadingOrTrailingDots, ''); + sanitized = sanitized.replaceAll(_multiUnderscore, '_'); + if (sanitized.isEmpty) { + return 'untitled'; + } + return sanitized; + } + String _buildSaveBaseName() { - final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); - final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + final artist = _sanitizeFileNameSegment(artistName); + final track = _sanitizeFileNameSegment(trackName); return '$artist - $track'; } diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 007d8980..81cb6c06 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; @@ -41,17 +42,7 @@ class CoverCacheManager { debugPrint('CoverCacheManager: Initializing at $_cachePath'); - _instance = CacheManager( - Config( - _cacheKey, - stalePeriod: _maxCacheAge, - maxNrOfCacheObjects: _maxCacheObjects, - // Use path only (not databaseName) to store database in persistent directory - repo: JsonCacheInfoRepository(path: _cachePath), - fileSystem: IOFileSystem(_cachePath!), - fileService: HttpFileService(), - ), - ); + _instance = _createManager(_cachePath!); _initialized = true; debugPrint('CoverCacheManager: Initialized successfully'); @@ -62,12 +53,47 @@ class CoverCacheManager { } static Future clearCache() async { - if (!_initialized || _instance == null) return; - await _instance!.emptyCache(); + if (!_initialized || _instance == null || _cachePath == null) { + await initialize(); + } + + final instance = _instance; + final cachePath = _cachePath; + + if (instance == null || cachePath == null) return; + + // Ask cache manager to clear indexed entries first. + try { + await instance.emptyCache(); + } catch (e) { + debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e'); + } + + // Then wipe the directory to remove orphaned files/metadata leftovers. + await _wipeDirectory(cachePath); + + // Clear in-memory image cache so cleared covers are not retained in RAM. + final imageCache = PaintingBinding.instance.imageCache; + imageCache.clear(); + imageCache.clearLiveImages(); + + // Reset manager memory/index state after on-disk wipe. + instance.store.emptyMemoryCache(); + _instance = _createManager(cachePath); + _initialized = true; } static Future getStats() async { - if (!_initialized || _cachePath == null) { + if (_cachePath == null) { + try { + final appDir = await getApplicationSupportDirectory(); + _cachePath = p.join(appDir.path, 'cover_cache'); + } catch (_) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + } + + if (_cachePath == null) { return const CacheStats(fileCount: 0, totalSizeBytes: 0); } @@ -93,6 +119,45 @@ class CoverCacheManager { return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize); } + + static CacheManager _createManager(String cachePath) { + return CacheManager( + Config( + _cacheKey, + stalePeriod: _maxCacheAge, + maxNrOfCacheObjects: _maxCacheObjects, + // Use path only (not databaseName) to store database in persistent directory + repo: JsonCacheInfoRepository(path: cachePath), + fileSystem: IOFileSystem(cachePath), + fileService: HttpFileService(), + ), + ); + } + + static Future _wipeDirectory(String path) async { + final directory = Directory(path); + if (!await directory.exists()) { + await directory.create(recursive: true); + return; + } + + try { + final entities = []; + await for (final entity in directory.list(followLinks: false)) { + entities.add(entity); + } + + for (final entity in entities) { + try { + await entity.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + + try { + await directory.create(recursive: true); + } catch (_) {} + } } class CacheStats { diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index d87386b2..0396cb25 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -29,18 +29,42 @@ const _builtInServices = [ id: 'tidal', label: 'Tidal', qualityOptions: [ - QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), - QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), - QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + QualityOption( + id: 'LOSSLESS', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz', + ), + QualityOption( + id: 'HI_RES', + label: 'Hi-Res FLAC', + description: '24-bit / up to 96kHz', + ), + QualityOption( + id: 'HI_RES_LOSSLESS', + label: 'Hi-Res FLAC Max', + description: '24-bit / up to 192kHz', + ), ], ), BuiltInService( id: 'qobuz', label: 'Qobuz', qualityOptions: [ - QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), - QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), - QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + QualityOption( + id: 'LOSSLESS', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz', + ), + QualityOption( + id: 'HI_RES', + label: 'Hi-Res FLAC', + description: '24-bit / up to 96kHz', + ), + QualityOption( + id: 'HI_RES_LOSSLESS', + label: 'Hi-Res FLAC Max', + description: '24-bit / up to 192kHz', + ), ], ), BuiltInService( @@ -58,8 +82,16 @@ const _builtInServices = [ id: 'youtube', label: 'YouTube', qualityOptions: [ - QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'), - QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'), + QualityOption( + id: 'opus_256', + label: 'Opus 256kbps', + description: 'Best quality lossy (~8MB per track)', + ), + QualityOption( + id: 'mp3_320', + label: 'MP3 320kbps', + description: 'Best compatibility (~10MB per track)', + ), ], isDisabled: false, disabledReason: null, @@ -82,7 +114,8 @@ class DownloadServicePicker extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _DownloadServicePickerState(); + ConsumerState createState() => + _DownloadServicePickerState(); /// Show the download service picker as a modal bottom sheet static void show( @@ -93,7 +126,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { required void Function(String quality, String service) onSelect, }) { final colorScheme = Theme.of(context).colorScheme; - + showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, @@ -112,6 +145,9 @@ class DownloadServicePicker extends ConsumerStatefulWidget { } class _DownloadServicePickerState extends ConsumerState { + static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; + late String _selectedService; @override @@ -122,28 +158,76 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { - final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; + final settings = ref.read(settingsProvider); + if (_selectedService == 'youtube') { + final opusBitrate = _nearestSupportedBitrate( + settings.youtubeOpusBitrate, + _youtubeOpusSupportedBitrates, + ); + final mp3Bitrate = _nearestSupportedBitrate( + settings.youtubeMp3Bitrate, + _youtubeMp3SupportedBitrates, + ); + return [ + QualityOption( + id: 'opus_$opusBitrate', + label: 'Opus ${opusBitrate}kbps', + description: 'Configured from YouTube settings', + ), + QualityOption( + id: 'mp3_$mp3Bitrate', + label: 'MP3 ${mp3Bitrate}kbps', + description: 'Configured from YouTube settings', + ), + ]; + } + + final builtIn = _builtInServices + .where((s) => s.id == _selectedService) + .firstOrNull; if (builtIn != null) { return builtIn.qualityOptions; } final extensionState = ref.read(extensionProvider); - final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; + final ext = extensionState.extensions + .where((e) => e.id == _selectedService) + .firstOrNull; if (ext != null && ext.qualityOptions.isNotEmpty) { return ext.qualityOptions; } // Default fallback options return [ - const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), + const QualityOption( + id: 'DEFAULT', + label: 'Default Quality', + description: 'Best available', + ), ]; } + int _nearestSupportedBitrate(int value, List supported) { + var nearest = supported.first; + var nearestDistance = (value - nearest).abs(); + + for (final option in supported.skip(1)) { + final distance = (value - option).abs(); + if (distance < nearestDistance || + (distance == nearestDistance && option > nearest)) { + nearest = option; + nearestDistance = distance; + } + } + + return nearest; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final extensionState = ref.watch(extensionProvider); - + final downloadExtensions = extensionState.extensions .where((ext) => ext.enabled && ext.hasDownloadProvider) .toList(); @@ -162,7 +246,10 @@ class _DownloadServicePickerState extends ConsumerState { artistName: widget.artistName, coverUrl: widget.coverUrl, ), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ] else ...[ const SizedBox(height: 8), Center( @@ -181,11 +268,13 @@ class _DownloadServicePickerState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text( context.l10n.downloadFrom, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), -Padding( + Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Wrap( spacing: 8, @@ -193,13 +282,13 @@ Padding( children: [ for (final service in _builtInServices) _ServiceChip( - label: service.isDisabled + label: service.isDisabled ? '${service.label} (${service.disabledReason})' : service.label, isSelected: _selectedService == service.id, isDisabled: service.isDisabled, - onTap: service.isDisabled - ? null + onTap: service.isDisabled + ? null : () => setState(() => _selectedService = service.id), ), for (final ext in downloadExtensions) @@ -217,11 +306,15 @@ Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text( context.l10n.downloadSelectQuality, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube')) + if (_builtInServices.any( + (s) => s.id == _selectedService && s.id != 'youtube', + )) Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: Text( @@ -264,27 +357,27 @@ Padding( } IconData _getQualityIcon(String qualityId) { - switch (qualityId.toUpperCase()) { + final normalized = qualityId.toUpperCase(); + if (normalized.startsWith('MP3_') || normalized == 'MP3') { + return Icons.audiotrack; + } + if (normalized.startsWith('OPUS_') || normalized == 'OPUS') { + return Icons.graphic_eq; + } + + switch (normalized) { case 'HI_RES_LOSSLESS': return Icons.four_k; case 'HI_RES': return Icons.high_quality; case 'LOSSLESS': return Icons.music_note; - case 'MP3_320': - case 'MP3': - return Icons.audiotrack; - case 'OPUS': - case 'OPUS_128': - case 'OPUS_256': - return Icons.graphic_eq; default: return Icons.music_note; } } } - class _QualityOption extends StatelessWidget { final String title; final String subtitle; @@ -313,7 +406,10 @@ class _QualityOption extends StatelessWidget { ), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: subtitle.isNotEmpty - ? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)) + ? Text( + subtitle, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ) : null, onTap: onTap, ); @@ -344,13 +440,17 @@ class _ServiceChip extends StatelessWidget { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: isDisabled + color: isDisabled ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, + : isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + border: isSelected + ? null + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -366,11 +466,11 @@ class _ServiceChip extends StatelessWidget { errorBuilder: (context, error, stackTrace) => Icon( Icons.extension, size: 18, - color: isDisabled + color: isDisabled ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) - : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + : isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), ), @@ -380,11 +480,11 @@ class _ServiceChip extends StatelessWidget { label, style: TextStyle( fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isDisabled + color: isDisabled ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) - : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + : isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), ], @@ -419,7 +519,9 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { return Material( color: Colors.transparent, child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + onTap: _isOverflowing + ? () => setState(() => _expanded = !_expanded) + : null, borderRadius: const BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), @@ -447,26 +549,39 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + errorBuilder: (context, error, stackTrace) => + Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ) : Container( width: 56, height: 56, color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), ), const SizedBox(width: 12), Expanded( child: LayoutBuilder( builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titleStyle = Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan( + text: widget.trackName, + style: titleStyle, + ); final titlePainter = TextPainter( text: titleSpan, maxLines: 1, @@ -487,17 +602,22 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { widget.trackName, style: titleStyle, maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + overflow: _expanded + ? TextOverflow.visible + : TextOverflow.ellipsis, ), if (widget.artistName != null) ...[ const SizedBox(height: 2), Text( widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + overflow: _expanded + ? TextOverflow.visible + : TextOverflow.ellipsis, ), ], ], diff --git a/pubspec.yaml b/pubspec.yaml index 948feea3..a8aa5fe2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.6.7+81 +version: 3.6.9+82 environment: sdk: ^3.10.0 From 90f731ac1e7018214a69060e235632eac0f544b7 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 17 Feb 2026 18:10:44 +0700 Subject: [PATCH 04/38] fix: refine launcher icons and settings page presentation Polish generated app icon handling and improve the settings page supporter section layout for better scalability and readability. --- .../drawable-hdpi/ic_launcher_foreground.png | Bin 2316 -> 2891 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 1538 -> 1884 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 3162 -> 3716 bytes .../ic_launcher_foreground.png | Bin 5127 -> 5828 bytes .../ic_launcher_foreground.png | Bin 7462 -> 8262 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 +- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 932 -> 954 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 651 -> 647 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 1344 -> 1371 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 2034 -> 2015 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 2795 -> 2809 bytes android/app/src/main/res/values/colors.xml | 2 +- icon_android.png | Bin 0 -> 25370 bytes icon_foreground_android.png | Bin 0 -> 22506 bytes .../Icon-App-1024x1024@1x.png | Bin 26935 -> 43156 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 318 -> 429 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 576 -> 905 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 744 -> 1324 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 419 -> 624 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 789 -> 1340 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 1166 -> 2073 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 576 -> 905 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 1085 -> 1896 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 1626 -> 2976 bytes .../AppIcon.appiconset/Icon-App-50x50@1x.png | Bin 717 -> 1122 bytes .../AppIcon.appiconset/Icon-App-50x50@2x.png | Bin 1407 -> 2421 bytes .../AppIcon.appiconset/Icon-App-57x57@1x.png | Bin 752 -> 1296 bytes .../AppIcon.appiconset/Icon-App-57x57@2x.png | Bin 1664 -> 2865 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 1626 -> 2976 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 2528 -> 4704 bytes .../AppIcon.appiconset/Icon-App-72x72@1x.png | Bin 932 -> 1692 bytes .../AppIcon.appiconset/Icon-App-72x72@2x.png | Bin 2034 -> 3674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 1045 -> 1821 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 2155 -> 3943 bytes .../Icon-App-83.5x83.5@2x.png | Bin 2361 -> 4384 bytes lib/screens/settings/donate_page.dart | 97 +++++++++--------- pubspec.yaml | 7 +- 37 files changed, 59 insertions(+), 53 deletions(-) create mode 100644 icon_android.png create mode 100644 icon_foreground_android.png diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 5e7edf11c4b4e4818b1b11f003cad8b84ccb6bb7..fd5e939d530b8dcd42f25c2ef6ccbf857867be77 100644 GIT binary patch literal 2891 zcmb_ec{~%08&|J4Q`p?)n#qw|$&h2jkSkJhW(bqQVvZamQCVTG9JyXsa<|;gopKa8 zW^T$^Y{Rg*{k;G7zJL7wc>j2w=a1*}oS*0TVsDsTV?Ql$nu&>t-N;by=CRECn*gVd zc{`0ykcsIGmyw>1<)ci>6y_n%mT>3#3<^w*7LO3;;`(^&WG+A~f(ar5*#?2s zR#v65`0__X>bk?K7(t3Vb9{@bj^BHyDbxcx }A?=qN~&gwE>1c}8z21PJO0mMc1 zL{C6YvH~~_kNut0W#;-%RL>%C(cSSVQ~Nd^Dv4p~$@kpc3m;Q2E_sC%#VZkfzgT#(M)pg0dQKR-2#+q}G z3Cl6JAC+top}fxQCB|@~PT% z?9voT$7~4&5r*Ia<$MdfyQv4gYhhAJY)11$pTm3mW;AVXw{I@otwv6&EMuqj83_KV zfqPsb?cv7AT3b@Pa3U^F_>+d?Q$=t`{asrxlX|$uDP1z~g)4)t3;r+7hg{a3pOoko zM5jW7yPz(P`nf*aL@PoOQ8-M0Q!faye1FbwNHPCshIN`b>RpH#MovuEGQ~VdA901w zvC;FaQPivW2)GxX%MYG$Wi-l16|whPseV0(Z*Z*>XXYMCLJg2s>7%g9W~R3G*IY@0Z6BEQ`EpF&z(VbcuRaQ=uKyhONSAK!Su`L!<~{{?WuD z4OXwQofDOD@qyT7T{Z_VmU%sc=&K^X57co@p2V|)lfMoPpD2&1WqGr5 z(r#Q@g`8#<4H>vv_6QP>sn~zBaEf_K-Fd6F;)ohrJaW=jh7I z3E6c#bWPuFEt8X#2rlqhBM2Y1=R248te6;dV_UW>P1m|MZld^Oo7E}iEMeV#Keqk* zIXUyL1W~zXru6g4+1G$Oo7TV?=ddwY!XXbQw8*tV*sxg7|;-$cKyOCAr?$*8t z5Ij2a_7QR@$@`v{#ijL~bjC;Fq3!x*jL*uez`#k)vNt>8rJfhg1m2O-iH213bCg+z zX$74J^C^=?9e3U|AFQOd3*QndpMC?Y4Z$|vn-o|aVm}@x#-)}uTO}9wtI@g3O>T$$ zzk~T?)I_AD!Vf(4 z3T9+|#P;RPx~lcEbkwMKyl=zGuSuNEa=afpBCDQC^rfUXsp9#)UqTNk&jf276iVxm&c`t``AyVFR z{(P|p;`55J_9FJ2nGf+er4*qo(S?}$p+~D73xg%0z=9?WrqWE9! zRn5tpxXVU9r<8;y?!B)?EN5HT!Z1n68otuN9X1tzORMl)0_el!d-y$n8oaOuy!*SX78_vu^T1j3btL#oX+k5Ma%NK zNyeHPZUw6*;purNcn_&`AqeTq(GpIwqIBbE9|XnL{Dio2jVzUK-v?6HMB6JsvrnOW z=6fOv-Duvg^0SM-GR@4(2dfK&7IMjAb8{sW2k%jFlM>%=F)jYs8vtZlNc)qiQEmjY zZev<&womFcjIUY$0|O<^pkUuPzu|Z3x!{)4{fA`ZT*J1EUVw~%aZSxAcK{0{pvdS< zBhnJQ#j}owN77?L<7cU~CHQ$L?z4=bNsT{Y1LVvA%`%F&QXb%{spkrb^_wE$fS?KK z*QAjsgx)p`bk{uX+7J$tymWDm2d~(6ZHV6`J8yu*y98;^)`^DQo)`>(X4!KE-Rj$m_}3qI37w};rmnXS5QDQVi@JN_EWAkC%^OB4&H8mPah2&(7bnmXkgW7bY&7Og^?T3D=;lWj%s7N@OCG6t^ z9x!kZxqAGD0l$BnFZ}*b0hk45N&K^xa&}nY_#xKeTy) z^R3FIT9lDAoGONmJ$#ZPRWiLioS)TKzl!=J$^{OMvjQh-O4kJ-bfnxQ7X*)_!v{#% zEf82(<2n0FV{+s5RKPO7AM;~2i}j%}Krfjm=B*+GArWm+ht0ZLTL?ex{JjUr_$tn*GaNNU7ym`r`cqu%o8;#QttXKHN3N zV;sH5*Pw!6sF}x^{oEJSR8zOLO~AM9-$iIEEmIo6Twrk}ks*H_r^i7b43rwjpEapg z8Bk2azm;pN!8%A1x!=f57MS_hUS!(asQ$fv=6YU9_WUht$hg?1b{z_PL4z;tEuDp1Z1~K z#iXc$KwV~oTcCN$Q@RXVu6k!YU<507c>TF`)rSZ)4R$AiJI_c-V6#rkz7QqtAQfb= zulG9fQB$(IpNe3ZtuBtg&%Rf-a$uF$aYO{F1#5Cp&_Z-w6ws>kP^rb1fhMvxvJ`7E zTX{Rf`0KAE$1n54iAoNc?-oM1xzZtsXrvH6mSm|&ZjvQr8OD}<46=kl7<)99v5ahEm?ryJ z)3{~JXc)tcyh8SPF}Bflzjf}n`{6z3_kW(}^Z%Ux|2)Z-7AAb$65Id)fX~d-$m(R5 z{a2i)Po{ZB?0o=$bIi=>)}ye(wIXjH;R%tLkycg0o*9v}OY=(tQVqN%{Y$C&`J!2R z!q!!pWGC}6(_Doen+j3Gdw4~>vAnr`bsbHApHgFmxm&Gp37kuAkt3azavR0)UsH2F z?Z->xnS{z-BOIT7Z#db9I_u;kG52*l~uJ*u~aT z7Y{xZ90cbu_VjSu(CAyHs_suzxqc_IQqw+>2uNkz>xk#G^&fd@>kTdslyriQGi{PK zeEOB>OF)97p5g||tOz-Yhze`o^WnVTe|)FhaucnP|fT7#XY` z15S7jR!#mN>%#65WZ&11C#CIMOeF5=)6J+t6n0u(jdK_&a*jJ6sdy6;lLwNV@$IWr z5PZmD!Rsk6E`KWpU30<1{)PysOUVWyS**rA%?0$4roqg1U} zXs#g==NK4vN*QXg+LhDx@1q@e!`CGTz3%vhU;{akpxn(Ih^+PRhMn9x6`IT4TGyJA z{X7y)<$L}b9tKHs^y6`!Q9RS)1T7G|RZZkg`DekRZxvOl|>s}^DY2#=6LNNvy_zUqznm1@*sxS zTlUm%#HNErCX*xNG14VPUutDeNi|2%9t!}WY8T*}qU)SP8>Wj}MQ%L)*CR0(uxWg+ zaxTN>f6{~Fm$>!Fyw0pLn?s21cZsdPt}SkJd@5ci!UlMmNo*oFylM4Lk9AQ|JvONM z?ZTb*P80c_rS_$5xQ_i!-^PpRc zzAm*j)GMnunhKBYRy-_KdT>{;PI3rc95?xBT)A87gGL+2N($v`CWrUn?V>=MYPr2l zw=$|=pcJIsl&a{b%oT6HNNfM*n58)JU5vE(cv{$j$~W&4)EXHir+djm810Fu(nZ_7 zh`yDDDhX)$wE81zuU*}%SL16uxJQPgeD^APRwlA+Qj_WAQDXe{?A*PPRZeje`$2S3 z;*U6VeSXDCVnGSsZ8Pa0lozSpad|0KD`?M1H6J<2aF4gY-w6z zMG%0dH>$s#%{OBH?P&+>nD)IiV6#S^-$4AO;QTso6MKIT=#hi`?B{rRug#7IVg{sW zpZ)M}>XdPd_4t`T%;$zP7?w&xBr`9knRjkfOwJ7r+%n#&xIcN%lHe6)qC_Y+c|OBK zVaG4Nw*#w`$avjxP*HpASk#~uE~T%vTL*Esqbyz5omNm20pLJ1+s0O0YC-?eu8u`t zGs<-Yx)RpR7=OtH+i=%YyWt1h`w1^hLW30({`HVhH}Hd+I4};CpU>S74e1*}c9e<- z@vcdRJb5a8*pJnJdO-ayBp~ZzJA1LbgS{rM<^51aasO=bSE-c|-;bmWqNeLFyc5#I zO72p2m;vF~g)ItiMGt8fEt4Ib=_>Hy3REehF|O(<*Iu+IMhx@>)Ur~u$sKlwfsMOI z9*;iYaGd(N&yRlwb0@f+7w~)>>gp$mFO_Rl`Hpoj{&CaM3o&$yve2e;Y}%FHEb`GQ z`|DZYHk7&fa-k319KM_){Ar4?kZv)zu9Dwa$TYU`Et8&&4awFig;O$+Ig1E7vX|Wx zD$Da*R!?#p%K<7ELW|izByps7F->}kopTAWyo+CWUC&iHP=#eQ^MU^QcRFjNJ6~K~ zhFlRy*lY5BW8X|kIt2gUsel&qn(t7hPbK= z*hcO8c8$u`-A23nKcYjzb#J)VbASeBq3|WOsPGSl8@odLB&MFn@j;P^gg~Cc0m0tL z<@GnJ?&1Z3?c#6)Le0(SR;4DjnGqg?X=Y1l>3#uE#-|?T(>seR?>*G}(@mF-c51eJ z?YJ0Ue9%DA7r^S+bNC}&qsXnz?u8Oov-dj~Rs4;a>yW4%HzB~LdN)@Ad*2gow&Z|* z_ki#1N?YwKbx?QV=vXM--AT6AxtAX3ChME`)Lp0C)*35Nc~TDtnYvG! zpUoFeZzBR;koDjU@#0X$7~!8;3)C{1g$ImrboIFr-`Jg45pv*|zi&*HdshG@e2s+} z2@Gl?sw&o-e~wP0(bd0WI1Y%%Kmo5} v6Jw>rZH~~b6!X!(TGShi^U#%lf<0h#TAk0G)ojwqmjf`nV_}3dbbk3C(2{o> diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 48070c298cc64c3b3a854b41d53c0ef50e318e61..23686cb546e7b060140a4c7091471b2f20bcbd32 100644 GIT binary patch literal 1884 zcmbW2X*3%O8iu1TYS#v1YwuM{idu^5gj!l5LG8v;sfZ$>MXOXTxwgc@ZazOlLM~K?x!?-uGXjopdrNa*%;^n$KW3Ing_rB&EsGv~z8pO*s zx+hs1n}pU1sY^MmN73_jFfzW|?Dl)}Qs2|^V}SuRVHjc3 zv9+Ro@c9_2m0-32oO-8-?EeMXPt_PK>ou=piJjSd$+0ADkCfdtP|?o4sg_|cql>^C z#b=f`lIhLWzH<7JcKg5iRL}b%j4New?v3r);_Eo=Z%p-#XLJ&sr}N5o3APR7_+eiY zgUF@^QX7=^bm~*GyY)KE!{0e+nD@N2;&iwL4jkOb5uKLW>xn6%ZF<9Gbh%V(4%YT2 zx@E6#d9v+BMC>8=_~ZR>YU#Qr&KGT&K>UzOCtd($xoG2p7(9MqZ(AIaXYk;IiJ|U? z^`~XtcaeHYUv>N9{FH6|dlpqw))*GA`PWfhh4wbZ%s3vV)pK5X4fx1ZBx{18=GqYWK%VI<&4Tns>L+QVMVVPBg8;@J!% ztOUTz_;AkP9Q7@2Z=-6#twgQ^uF%?IQ*__RH;Ss!se0P(%)vvUS_b=*!92|e<*QfC zsWh5hhe~rw-}fE&l8={U_1v#$WVC>lMP#`|hQ_wMH&xlN#gCwE#xfiJOl) zj`st>J_Q9rW%P0H;QZw{CqgItQ0S2KB?ry~ zjPXS#36x-H=0jmLg~1imBSdy;z~8aI1!(GNdH}iS4AiCo{Z;=^Q&cGNATvi{g-Gc| z7hs0+bn}-KR1xaguZ-(3QF++ssgA4`#Go~E*FF!HVn?YPWc?mZ-n%)v1jaDZwzJxU zG|1^~zVA;0Ed6#_F!lwl5kVkSJ_5~&MfiBe{9zag%2A;!&$+S{wmR+_oW{08XGCiU zwHD2NpnPJN%ogBBFC1sR;9prHP?fOLw!#eZ_`SC3Z zqcbX0R$dZ__HdeNdNX-EtheGS0jQ=a|Bxqyx{m05c%=dX&F+v7B%j=pdD z$Bf1G{^O=Vd}ob%InyW!NTDfnACDG8hH6YA{xIz0ov+A|%>DSyqziPFM7pw+11?lA zSizx$qBKA2#NCmBb1sG_%gs%W1zW%LtD$NMA1COvOpPre!aozFs3Cc(ZN5Pyp;cK4 z5)zDzp*;iPXOs}SSh9)1$<*Y*r1V)_{P8;GXC3dfrdFR+FZT-{|F%h? zlhSorxtjRQl~UsGH8X%Dv@n}Yh-YKtjq87^j)6&*OGejEX3k&Z>-?gr;(b;(Jk6fDppwi&Gsg-n8T5ds_l5A;JpWhRNPQXT(X8=sxRnor*4eo`C z6D~6bC=6MZJ51;Fttz*wr?n@f16aJ6EQ*Bfu~e`V>&$6GaWb4 zX7chR%{1Uwd%PS}Tm8eoIyrUG*9qM|U}Sl12YkVK7r*mx_Kccsq%TD4F+FRvvnI;M zr7gkS>bc;b_B?rLj$4m9cp_I8_ljW zWy0h_n6O##7d$keKSE53aJqp1@tyRE5~hDB^eg7Z`5Hs_D{3P86+O*spTg$Gwik9s zQ$6Mj(py+46Hsh@V;q$*`>ng11Q|5M>?i`Oc+Lw@A&Lb5incCSv4~+!+U@=HlP(Hw{=TR+A^iiuB6jX446>!ln=@ zrk336{N3yB6w%8-aFiA?ebW2nhxpaw5_ISr~vhj4TK42J^X4K@1cJQ3m+4 zHhCZ10K3CkBXl)6kaz`$$l0iJ=Ek>3OOD02y%1VW{_{%RT_F!oAi$2rkrkS)EfYkB z`op~H9G0~2f1kp@x&?!{Zz@0C5ed=xnacCFYOd zM0V|j#KgY#8vnQ<@3kdzEVXCVaWpEVOb9&zn7(jdw_s$CxUhF2{h@EY3YHQOZt1;uY{ zS~$MVX-jP^+0JAkA@uq3@Z zv&uYRcipX2qMGnBj)InTsA$jI4p`R<=hm`+mr-V~eoRPf>rm{*p!@l$N0@BljYN)n zX6p4%Rd=m^Z`BUpZc`Wt)+E3MC43Su2IvH1^yWT9M>4}Y=Js-=6G#ZHz z{e)6~J8>+3Hk@$sUX2YEu)}=O+cB($o68{OOj~;UW5D71Nlh zf`ej*TOzO~%oiGT!&-(M*(v~X(~~H#R$r}w$H?*5*EOl$wz+WPmEaJgx)ti@nuhu) zhdjZyyn8vF)%__diCy=8bt!Fqkn^L&T=+!eiiCw*Hfm(KGZ>QjD$k@@*;d6R&@sV> z+b2Ui(z!Vn*yotuX2kz|8kV>QZLDrPZrr*_MbGqsY@D#My<8` zc4DASF}%nph}KzD2GbQODcajJ-uu98x$6|8%c3}9+lQX--CZg?j(*)z8Lz2nx)3fo zR3d+Rtm4a3{akD5UE9Wve;eY{#L>29z_UmvZVT>Vto%*-zBiF2GFS zZMSd`e7%p6MsP)+73(oMt~K>h(4nK?<$x91I-hzeTli86(PA-QXX>J_fO~v=)c=6s e54-1ySse= diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 6bea80f27ee20a95b7d0fa2d6287121a1a76afd7..093d5c26294d053b7f12e47bda7c238c8931316c 100644 GIT binary patch literal 3716 zcmcgv`9Boe8gT@}&8cbu7RD>b>e#L~b42Ce-ZV@A9 zB*r>q8DuhJ%VZnhx&OeupU?M)`@=cshjZTZdCzm6=lQ&E;$5pdJf|<5W@BUHF*Px= zWvvr`4*(}?PQ0kN&c?=TZfay;A62+chCg(m6m@T|Xsyg%xpF`H#(dGnn^&&rEdh6u z^Khsq>d}N^sB@a|duolTOP`xqDMG=DM`x_a&EwBn+)NOa_ItgT?=$~(-akjs-2gMT z27xouTNf_b0Ipn`ff1q~5qJPsgttZxY50^mV%iBBBuXOp@yZO07G5l9%hvPkX))Vy z5&!v%FT2?cI5+{9um5o-?!;5T?`Mp_PcE{bLsvG5r2%A;tw;4Hh}M~I&WiCf7NnjP@D)sloH9SB5umGAM9q0Ym2uPNma|V%Sj+@j}O; zTyX*yFlySCKG7D2rEdQe@LmYu$i7VKIiVWq6-uKdyl1v_{2cm=(W*?(5ZNY_VliU$ z>d=bc^G(532H0Nk+k1#9ygC3VTU>KaE!Gfe;R>-7w=EAc&qG>+ZpzOd)M?o; zLWk|B7TTCQaYx@KR%)-E^u1;-A40@K3q3NG?JGo)9Ke={Tj2GxG2cj)aFD?l)Ew0Y zep!S#)1kV%I4)bDnnGUoJLj$fDZ4PH8Rq{v7Sqz7p%W{ycNY74S zU36IpjU^nt8eINp{hLaa)!PGzFuLHtIA7V4lrDN%TlxL)s9NSJjW#ty@Ot?HMAoCU zSp1xeP)Qh2^|jJjE3u&%$W@1rx4%5N4PL*mqDZPN8~QhOtUJ(u<)Ar$ul_jy1t|7j zmOT)I#sx&$l^mW!A|@x`=elFR+{L)i(x;k2*6`2wpZH{D18Ry2=OiA>9ZoD!K)|SW z+LIP+fEzet?fzShcXQEYVQzk(HZG%-9egrd_BM>|gsB=iOXciw%^59})zcNeKFTnI za(l?cz`g^NTcs6C<&HC!A1-Ch*x%i2N6JTXq!Zh4TuY<_~xH@$a<&t!3J7iCj zthwzIu5oX@Zg15ld^Lj6*7Ww3RQ*{Mhe($~1X=qA$~*R90Q@jszs8!9+xl;M{y%)r z_EHdJnr{X-(;2w>*iIM|3PLoEPCrsWO@mxo_=-b_iMswQ?GxdQo|qLBUpCr!#YApp zBhmLP?8ivaMcdu2br<>9Ia!O z*(ML=YmzKZQ{${{YV`fR1f|eHitcQ;tk_tmr^J2;R;&Z@W%7g8_#N2_HT7M56xI=J zZUI5$Enptv$^q3AYcaG}c|e+{8mHeB(hsrlAIVAhq*gQ>g<5ceKImN_Frj}&a-2KE z_&ESscCA5w@42(ZxX--yIM9Un1)pj@^-EsG_#Ab5iyIRyH;de3y<6f=UH2KsCjxYQ zakO=UM8VmyOFG|68amY@oJVAv?vWnsC5$tt3ueRlLG=IyaO{1;*W%NP#R6mVOXn&+rpX(poV)>L)>|JHch>sIlJl=z8T)2vAsAIRXMn4*{g!rdIJa4 zyb5JPJreg94!I5_`?B-zZb2M|r1e&uU2N7mZ@Ieh#5KoB6uS6XipF%&vht|#7~BVz zTd7c@yoAlP3*>V0R8t&Er4d<=oyLUn@A>^u3Y|+sIQdyhsCgiVN|sBG8MDAn!ee`^ zY0O~69dPY%*}m0~qef&rzA^l;ERlkUg7u|MvXlZfOQq5(UJZo3FM zJvG=UvF22;Q+_{LA04~$$%`e-k_Zo-U#&Y6-;=wYJ&6*{CXj@%PTa-GF8ff|WB(Y7 z*&r;&r(Lb9RJ88Z9pR5dod}t3Xq-sLQg)%oTU)Bb+>mwg|5U|j_d9CqtZH!(IDm0=$ZqHGziLC6 zcNe_+-Sg7HGH0LEJAgCI!ZOa6+0WDh?pygkWxz#@M~o4ym`O2k?~;^U*zxiU^b`a>!BjLQ*gj+0zUj@+-??c>dh|Lng}@P@`Vwzwu7OB?P|m zX+gflZX4+r6NNGPRl4~lYLb=^N3eMb^anQ_K{XXkb_=16%rya2ZcHvSbux58oH0ba7X`C^U|OJ> z%ql&lVAjoD>P`LmdO@*X7@nfTeh8s=t*8Pz2nua`K*b5N2g(yiwe;t-lo$6zPT4QH1h9e>402n)NG7 z2XT6v_RDUjSc+B3!5mB&hoK!O0wY(&jhO;64aLtaBxcgbdY+sdf!7WnD^ E0IH<^ssI20 literal 3162 zcmcha*H;sW5{E%77)3&h5(SKuxFTJVA|OfV1f;FBfRxZgT4;t)rGze`f)o)@1f-bI zOF%jzT?t?aQRyuq^dfNGKfrrm?%9W#d6;>bGxMGKP3&C*Ee-$}z{0}9p`(p3{vF5u zG4^A>{YhATJqycOhz>&CG%#z08tiViDbU4aYzOo|)N{?f|JO`zSQuXiNH@1*H;e}* zOMNVR;NWCW?CW#o?K>vdc~Bw2ABS-XbFW9oDy7ZKrgp^OFWj9w84N>As~?ZM3*dcp z^{vEq7R^(!CTeSv0&_KF>~2jA=sDEB^V-p&Wd-$YBXXLdOL;N4n9sn*Y?)=|hYd0rd<-rI+8>Q4_X(H77~S1dm=)W8<^4fR#yor4Imp)N{Pm_iVp)v7 zuZ6r}pByFmgkmw|3}lNZxo1&9W4SuE+c=!V1rPrpeJ$%mcWBfVq*p1;2o4@*w?27VzUGw53tpXg;D|KEQZ`w|K&^s-~ryq)`Q+QOHyAXGw_+^jn1B*VlT|)1 z%5u6TS^_Qgemb^JfOBM-uq8Y*M8jR$s6C0oMk)ljPfSgmeI@robxde14BMqq=Udj= zFM)RWi&7wG;PqHHs**irjmRyZuAiG~oA6vgr8QISMQS(@-qJaJeqmS`0p$GiLPAtv z?xVa%uO@-mccMso$qNu9qd@L9#6yfVSi;ZV4D&28*JCO5$%De8o86GN2yEoeoQL&q6ma=wHf1 z=mJg%Sp*$ZwdfUJj|Om5Y*K=<(yC9Y6q;(s14$qn1}EN!`NJic9C~-B<;UQb&Hjmu+ zcK`GV4ex-lb$?Hnz>{1sk;t)*uQ?lZQo7o8)$`RLlzN%};pD*n9hsEi0sW?mbVX)T zVS%FVCiIl-th73Q&Sk2kxGLm;gUDxsiw_vM4DWxRHes(7wMggxWGib_n)6=9)vKmU z6^2JmNNDx0A-9Nt9_?QaMgg|2R?CgQENI)aW;^-Zhb?^n)ZbG>&3qLEuRdvGgLDqv zjLDkGT<#KAY|>!3IT*>vY1l5B#K(18S{H+mhD};^vGw#dq~x`P7$DC=y{5B96{#db z*!va<>g4C_Eg~geUyzh_hE$M$rDsp0uKtnnT3RC@r);=T`ZR1gyv$=TbkbhM^o3XU zQdQvkG+utltcrAC=|l{%A!DLC=bJr7MmQ8?^rL}6vt<;$=#o=T{8pmYtHo9YV_lSq z#8b%O^|C9*+r6e|pip}M1)s_grb)nxpy`-!&IH%ZC)bp8Z3;v#B=gu?-8~?$$hpZ!=VhZ55lh85 zKL@TE^rAr51|>-UtH6%x<{q|2)NMOtXO1Re z;ZEag?IU#j{w)2jAQ*lzZ;yT>&P-cCirLqDPfoKzIcb-OVJPkGf|pW!l#ZI>Wq3#W zO)gR`X*NYahT3+jKM&+Q!G`98_>?_E_CKfQU!_G<#tpmS&?87Y>rmpm zO`0kPme=mTsE8+;km&VN!Uy(;_^b{7K*1EgLGwKwzFDtUt-lSkIlOK^zE%mz$S=Lr zypvLIU&+bCZGwj;>t~&vOaR7!D;OeFY347urzWlBSdw{@eln6nQfm2d5crY`jkwtV z9qq-qqI3FM2cr`Q%uKZWX|0{L4Y7pDDh75M`@Q z2|%I!l*{Kg#*KpKeWlsJxLl5*%3fnvRo&ZH~0UaqVIlCO6_MOV9Qnm)R#6pyaRQ8G4S<$c(&^-hfFlWv`8 z+yTOm8u^B=(Lrd`)7?9#6yOwC#JvUlG`Of_Qzz}e9Va=#G;|HWCL!G!G-57VDw;;* z!fw~qe*DSRi|uowf{_a!j3uWbSlU8?9KB*>i)R$<`MgcAi6(F{*!ua8r>nb1A~FI} zck< rrEW_dmk5R*H~QnX{`Yip>k(_XdboAb2S)YppMgb3!vIkRyC3l%+ETpj diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 83c9cc3a29f989e093276e3838fbc559e4a5f1fa..590c5cdae7a157a5984c520f929f865fbc39265d 100644 GIT binary patch literal 5828 zcmds5`8O2a-&gq{#7MI5ODKeFF*G$9OO_B>#)QTmG4`>Pim~UTv1TZZb(oJOWSh#G zEHU;qWM?dc!5E%-p1^1Vb>$D&+m?r}`nB21%^Ad zVkibN76}HHXcmTOvGXtfKQ6m8%lyG;B0R-P_?o>6kS#_%8r8FHyEvm=cGZ4(A==Zy z?~R4NZHL8J zhnj8XV4y*=gSgo0y6V?@p^qflyY}1-S-5O?-%O6Pv=#cymBOWtziY=LVKKO%-?KZT z27jZ5#Z83Z+Lo?WCJFbh{kXI*Ga1w^_|SYc7rB4FKQH{FW2hD7)fY;xdjgG`jy7E& zp|eCd$CJb)O`f(Wp za%A{Cf%=dZXI$mF#T~IQ*4AOQ+_Bx$IVD@Vnqgg5SnDpcbnieCWzhXtX7%7&ho(`A zDE}>Fva7BN{Egwl#;H+_=~J#1E)dvXP6JtGReME0S{#8(uDuWDgr6RztOPSE=N$qUF^z?~t4B}a=DcBzqL%dw&+SiE zgRkBe)cehLD6t~piw$%eKHu+#$BjlB^F!p^Tp)<>;q~B>pKE*nMLsC|AiZsPqn#Vs>OXI{hUH6Q4%A6Jzu>uwQ{F0$o1uzHyd-0bAq#vdy@KAiI4?8_sI z$4-5;=0Hj||0AolM*Q(Ilu{v*S90_#ip3eTJ!39%%k6xK;yBYCGl56gAQ6F$0sm}~ zM^;`HLi`2|)jhAd&H)=_-8tURE9D450OZ#Pt9QC-X5BHvv@>I^XQT@e9+iWn7Uds~ z1qxU^KlT9;`AVIai3!xh`v{(eVdx5FR5s%)%OJuTJS6{DDJ)ZIYI%};g73L0L*&lS z1GMQlsc+hNTG%@0r8u_dLSJcFIvPj@#Ss#cJDK1@>^Gt_l@2iOBWc~GX)T2`KG23_ z_2%!$&HBcW{ZoNW_r(p(DfLW*(-XQRuCx6S^y#GHmKrL3pgS_HIg8reX5w|CR|jd% z%i+_ZWWQF{w@tV1%bxCC3b8TnhyfGCQK8>!L{mEQX0Pt|HQzoQU^a2?1XA>%DYdVM z=1kSC2n+}PGZl5E-L9)~*q5XfckJSWiBsBK@|dOXYn9t#$Cp4ok_SZJ*uA;2g%LNI zq4mmEw3e{V!Er=ypK8upZwt53HwW3tnog2$aV06U{wt-Ed)>tLgG%zlgNa&Ks#^}p zA~y(G2mzmop+aR&r_$ea(_&6}5EHq}wF(>IK7xe0(8PaVB)SBT!44&JEe}#&3h4t< z9dwTJE|(__b`*P$x&F`!v$OpXojE2dEhuxpRsv<=co&(q_vF{M`G#kZ|1B&`Hm8+*WOh1^A^O5Pf?Nc>#|JERfA{6ljadA=P{kN7EMy`HWoz<hfpgk{ds(R((pS^-V8w zbui}%z2L)>8;G>YJaDnCGfNovlM=|GOuCj8suV+brGmt7*>kFC`v&8B_Po0y8M#)q z0!rH>=P0(kUqu2tCUeyqrx-4S_AdU5ME|ddgQV1ov737u>Q7(8bJzPNAb7$z54eOOF{@hRp)8V+CEy8JwMhpxr}Tg zO7zBu0X^~K+}(TOUAl~1%L6vh%X>Gn`Tb7P2jilqIM+!1CKbhGrLsu?G633DBmAsq z4+j*mH78L=dQfU#aHImxpniN)`tE6OFNhm)RU2QU^kXcs%ZM&)C(>_lp>(XQ2E5H4 zu>?a)%7_146L3y|UCi2KF38HRh7=^o(~R|oKXtQR07g1Sn#|y503->NCNrq*sG?Eo z`2nhwInJDeEH^tpo;ntBDX@_CI7mpY0TT0b>DKB%bK5p&W|aiin9ZwqjySHYU3DJR z7K*DcNF)JKZYy(xGT)JzPds2EVgD8$9H&Nnt(A=p6^x&sFM2NZ+7o_Pf?%sFl=lgp>d$;qorf-wVw9>8SHlS5Y|U z2)Kddg%jMC){>+x&cHvLl-}s&W7UWWA|l%WKnc_edOYZ+r{2ExnD$x6KUxT>OruKJ zQHz@7U?8kDAW&ZS$>^1c1lRkx-T4}yd?~(ydBWHgnHYl@I((pFp~h1eZq80yw#L1g zSOxo?bg|BIuBo?)+Vieaq_s$c+4of?kB@KR`5S~F_j0FxG&M#iWt@71DGw zmYsdeMyd8>sKk?&_ZOvkTHpu)=ai<)D)z@7I|s!dyQ3k?x3UJ_ybSp6ooF}(j?=5v z{qy`O>J|X}slEB(AdjP6!f>@y1_$U>N~w~uTSffUTZy=gr-8x?=TX~ZnTci z6T7jvjuIcFr$co&>Noe%6c{IUt66K~6E&B$xto$}n_6+-OVM+u)_1N4Rl%2jpw`+p zm<@ZD;@=C1009QB1-VTAt!R9GR>OzA(d@h7XQ@tXr}PZ9NxUT-THFtbC#ki*QI!bu zKTfEaF1V*%b!14*>LpY65Kdi^s1;RYFGjkrg}eMO&fa9c;9TcYxRRaR_1`n@B_=q_ z09bjF>GAaC^>5sqo|(i?({&5j>Z*ynVqV`s`R(S?;pJ!_@Ys4 zuIf>lkBe5mxY<4C&s|EoO0Z}zl~`nJPPdSbM32}rb3QO|t*wssyq{SDF%L0sDX+i^OqKHO+iT zXHcjry(f9){Ong@Ss9;vcX%VG=WidCV>&t{sMvFY7tWfLH*z!-Hgq$>?nH8EwCEI{ zyYNEnPkuNWv~&WGB295Y(-fG*9=mX9Ke* zY~W$KdvrJg`fC|0bf1Q!-VMOZb)@6jLJ5|_18=N%gKISP+eMyT%(~IyxbzQXsE^l(Uhy}1Idhps#E(P^M`80^x^-Ei zQHjq-HXH{(`<g@$R(K;_MHB&GhJAmIsC1+96VY=pb=FC zS13#I%Lmt^50vOw$S*eMI<1X7Ubf^>U^!$iSF}o7dD8p(^d0~)@PWAL+L>x{Tsmm@ z!?6&;V|{pfTxj6kew_VHP%(;5&|hKt9k?F2{PnRas`?diQ3wn`yd(SD4>eRhwb=bE zYG+$#ZOzLkyn3=~PaaAoQknhQnm*$Bq3f_)L>R0)BX7O0DNiTx{_(~7`tKcNB@Uj38poZS=^w8=e^ zQM&X++)7SSSU@8KavNQJ0IU#Ql8w%6-GDuVu708IhCv^`-wU+k@W8Ei%b(~ql=mik z+d9#oh+Uh{z0o|dVC))tHh;`>EE61RnLXPn%R|iwUYjQ1m_l^e0nwR1y@?{!NJQ=y z^7EyJv7BRvKH5iN+k72Zp2OA`azofM`k*M+X7)_UZStkbT8Oth-e%8s@wT}I912q( zEiGy4&3>#Rm%IMPtsWBVx=&&%a?(hcL-2um{9H#MbDJ%uZ_?sr53NAHcS);k=ZErh$R0NdMONk%H{}AOQJA^-*gv zZOHzi*J;g@hN9qQzfpx`!HV}kV<=nCZTr?v8-IV7B0cuN&gczXrBX*9PrPHM^I{Z! zhJV*ow7R3T1!r31C^ITqVA(9LpojG{j}W2gqp>wAv8KTRk#&{&!n(ctV>)*LWP=lk z;8IvhAaME1X8di*gxFZ;-%Qzp_b64b5ZhLejnu-);cu30H_ZTT;r=Q?Qgn`@|E)YsW z6%tfJ3oQf#5d!$|t+(Fa_uaqm$E>~9tT{7t*38-aoRj>(-0(6ZFC!ft-R1j6dJpO7 z&guOb41b-?Oe9UbqNC#+yRUcGDxz>3ANi7hKkPG^+U6Z@J3rn07oNe5E$iI#TQ4s3 z@@p@pWb5dznDe&>TLq?#820{PdP%q76wblOkgIb&H)n)PLyGy+R!-^#&kC+y#p{>Y zbS2w9O#6k^+F-{hq)Hg~f*qP!_BF8}l6G>%st4WhvuO(ods{`Dy?ySxE^_faT{VmQ z#lq`epD+D?f^hi^kK0kt1k~ke8N*~Yv8gDLDMkfag@|H{+$jNtDI+Q(t;MSQhlY#x zmf!9gLp!VPnXxqjd3`svgyhXZ){{MaOO&{SXT)G)6Cv)W=t5lW;`y?pq_2|BH{}Yj zyq)%Y2yC3?=t&Q*x4X-#8}mS>e=ECK>+YytbAODtS#{r?YAyGJ`{NtR$;Oqkd6|tR z^gP$ST8;CV2a|XLpXHyE{1<@25bZfb_ttk|b>UP;m`nNNqjeE%F`UVKddI`fvhIbc zkU*a&FXM7q`XnWr^4dp(=e^0%$ls?*v7P!;UYx`B!YXqMA|FaZu9i%E~$=1 z7Oy*zo}ABT1zyZq&l|xG=vtwbEmFses##;Ez%YqO!kvqVHJq=68?v!lZjk2yuWNp}tlsGG?Nb zOOj9$O>*$=ljo}bTXo!Ib&u%SArV?;Sp1+1-=zf!uH;p-WJs5l!Ac@nA$Pqn`7jD8 zg*$HAE??UaU41>_zxGd^v0$5izsNMOm{X^_{nO7!&Q&9NCj4<^4$w$R1g+l!wF!`y zwJ#?B^rlJ+lrqrZaT&!bZa0iX44N^q*KXmgG`6+SVywV75ZGfKU)-T(Z zgWzSmt#9nh>;Qd|30Y8P?NVZGP|HWPpQVMOs~b#rJ^c(jt3D^)LpPVof|6_5mA+q4 zIB(AOpF_w#apIY6;W>L_m|r!IPNBo~Ro(}=_(YI{;n17%GV%Ru(*8Xal-u~{QbL+8 znFW82HJKfZCDfa5-$0#QT40@Zdy6I($I4kYQn)=KaROd@8TtMq$J8TSrRB;C>w42= zBjHG2VCUOmD-vcI>i`;>n=^qeqHM_#<3 zlms+z%Rf647j2Fv2?L9jiI0U)?M@y&i@3`uy%7*L^dU3Oix&xND5e@ANTU$ zz#k=kZgN0fcNEB=it*6r!9QF@vVZK-ofKX*s3>zIoce^XEsz-ThGL$IpKCfX;@RI~ z;o@F(l&R>yn1vM7L^vA?B2jRFkqQBI||6^{mW@AHrDk`*-j zaTB^E_Q)M0E&BTF^-@ZH-@gV->FRjk7*ZyQQ&ZlE?8iMwLiM2e zvv<6(98@2Z!rvP!=UrX=5HNr31mm5wOGml!^Biz~g9HP8QQ_5#mJC_of{_bT9BUA# z2cO8k8cu!GacK@{)36y678D#0;A3b;oFko6P|LTDT5j;=+L@KwMh&EyC4O3}`i9qa*#f@G_>ETxh->z$r)Fu%X0o?m?ZcJ0OqeVD)X8ND=jr zfM~*Bh((K2vOc<^`&w6PS6gOAwjMfN{42D->(B{2BqC4*m<@sbOjAHXoN{p}k;vwD z2iBslHf`ubN^|^tUd^smmf)@63bv~NR-TTq!9cc<=L(njsN4|ZFg~at4UA!2Si>CR zKEM~?#qVYgZfkq!Ql{h8rCozGC(8$xTafjKbcxSZc-_HzZXv+E?;F1oQuPVOuCsX~ zalSu3r;isdpVk|R@lIaFhx#4`zfqiCdJcFos%0>oo_MCQ*k=4r^RJ^w3qzByMGGQ| zsLqw*npCP*NyjXAr|*z=xPh9}drRpZvSMR-{BjR>-(JxRTz(QND4t8h6WEQ5jhqib z%`UuGY}TTt*DPl|9vYz~(AbDU-`C7iAgEpknJp4;bO}!F=C2Kyj<}kvJJ_eCQq3M$ z33Vw29qu8#b7yW*oAnPWE8RACGpK3a>6s7|&Os*Nh~81#!SpILUM>eWk%TI(^cDcm zmuPmVQ{z)Fy0XL19K_%34*dC0Lva_}71CxVILV6-eAo1a%fwtKkukF?01*{gg8QC? z!W0%B*s4sKJcw6r7{ph(pmW4GVYwvWE@i+No^F+@B?kvNZc}vIh zl1gft?%=;99y+y|wFvm32*XS3cI6ESA2Xe}E=81orGdjL8zfn5j$UHGmFHL!)%*Ol zej z!b9*mk_J;jlp%TDtMv1n!pS_7-e6h(?qp#cOuno^R6*i zq#tLBon3HVSkX?d>%Q5v@=dd;aX8a0yf#0tBU+M_jbKtxD`;AKys*uEf&sz(qq?wU zG14}T$~p&<$x7D>#f2uJ4lA@E>&fuYfmY|Y+bfxpJf~_cUAlG+WuHD)8GmCd5Lxx& zQl-p&a7NX+S{#a1L8b64LUIWM&a&-aJCacL1xcXA65May=*eT~eEB}Ot0 z7C}=od|xdR2E1E`?qm@OG=20m`*?9a4L1<(FgT$qsoK~~W2DwK#GHI(e&jv`Y%MU_ zA&tQ;%sF$yB1DFu3UMEshzh-_BNKv$?Cv#KCq9V=iMmKrah1#S;&&uv6 zAsQpN$LdhsJOx&%+OG)O;wGwFT}N!-+pTWDz&Ojj38pZ6N6R%+hC@Z~sL`!QwsS+G ztA01y@4$`Ls%sN@tfHf1rtpBcsID}Bfq79c>3Iz{!AL3Mymd*SbgfSol4LR}nVOH6 zM$QQXb}*Tp0Ee5?M~p4*-q_}Q8QjTVWZc7L@`o_b&V+sMJW^CiuwRUTzRAF*O?k))hC(IIv1YN2Dm0(NW%dxR7hMFm zeV5MfQ(17Ebol~);Equ&#x@hft9!+l;#dXm+^N0a;KxnUGZ$2_P5eO2kk7YT%IIlL zhVAaHCAlCYC1zW?Lsj;8#A`!NG8&`>Zy{VG=t?~_VE?g{vHG@y~*+#uC|2@WAK21k) zWDy5>@cP6jq$MQM0BxPkd}W_}nOst28B8cV)kO^FU{D%$vbaWUxM1+{@I%9u@=ZVr z_oxw|$QJ3@SYKYZ>j$0bJ3I^2oao=p>wS%$d+Vi4$t4lg@9AeEe7aN9%c>?01Ydzh z1x+&HTvWA=CQ%qPd2fbE;kQI1r9c+fj@|xguf-Ok^3EujI_yJUQEySfr#pL+5yaB*a|owB)vQcgh^v5&@tmSO_gZM>y%wZz(1>4+!u`K zX%QL@H76yZUO!6;Am&EJUd6)-nwO=EZdOWv2Px_lkyQq>g@TJKHvWOX?XG59Bf2&v-<0C#TaZ-|-$M91?3vC=!MnA)yQx90 z!l!tpNzT0wTTh2XlagyUrJ_UP#VedR`<-M|U%oJ$`DX)sl6a`7E=W{ytQYEhrP(l_ zRk2w9Ngu6prN4I^s$UW%-5T-AlvLf6IjeG(#6^A|@Z=hNG<#KH)@l9r!%v6AARC5Fm1I;dV{ImQnN(I_XEdj_PM^I4M4 zC5~E^2_)7+t_Nsc1KrB6os%ETuDH+o*gv@6Nr?E-Jzmdc$NV>}V{!8H-~XT5O#jbee~?V09}CbKmY&$ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index f4b452d09a7e618ab6d1ee449438cd6a6b2e4911..1f54097533143de5627bf803a5db328d24e48013 100644 GIT binary patch literal 8262 zcmeHsS5%W-*DW9-3W|s{5m69>ASI#{X)3*gRO!7#3@t!_U;*j86s3h;L_k85Pyzv@ zNEbzlKzJd98ag3BNOIzT#yEH9?mPd@`JXYKo4v@L~{t>P9E~^>wE9s zDYh~{XDB_FZ0l&274(l;wfU20^{m#)F7bW3lGs@ydhyDFsLlE9Cv5`{dbn(MKr z?AHOmQP-|szBtc+_bEdjqLz9y^`#k7a zyX;juy6cxjm1t?jyosWxGZl28qx-%F-Aux%9kK|_+WKhIV(GuBbw&v?ryXnq2|#(jPCcq2Wyupv%5SjR`X&kX zW94D`8$f$lD^?*AjrlVv4|LbpI@?Av{?`m^dbaF+&kY7}ALmcWy1sOW@eDP~m^%93 ziBa_5T<)cK-JAnlmoYi_Dx8s(uJ9WDIl2n9PC7bKb}70jo&W66Z-j)$VGdtfen1-3 z6Qp4`{mXd$&WHGxvtjdvxaItr)Q?j{h((y<)BEgFrsC2OJ|?ezqi`!}p4ukd8~_6^ zxK>us5@n%}a~nD@X6x~LXStm?j4C>+&mQs*xYyVIBR)hBonSfW8(tztOy{=vlbnfu zu?}P?d)jIA0Tox0^LBP24~X?vgJ)8+z#4Gy(iBfB+sI45kE526Yw2vgI0pmYo;PT# z#zE3A-iw=wJlyfp-2w$`QJEi(yDNr%1Ztp$;Ay@M1$wX?3j+O1;=EXsomsny zl$~7-Hs2~TNz`LxHS2FX95xT2EWZP%6{KcieKzfZVx9sRcp#!ZTLODEp&RXgR4-yo z+uxP71>7R~pko)n3mr+}TQ>63ow;x+gPC6S_eF}Aqo@ku$urbG%o>|86|UiYe9l&r zU4Iz#vHaJKeYA=OD)~%dK{n0$k#C&hc!|VBXWdmPQ+~eXo=nTG*<9XFcVR?bYwZhW zJ`I4oitg%(7C%-TGrY0;nx?P4o^=`p!9hK^Rg3fZnk04$6;ZonFlTz`YK9IQeT5uD zOeI!5fww3ACXvnwEdm}VI6A)6FjpjlC47#~n~MFvWU|3gxXRbx6FlJAD}inK?dv)z z7rr@j8G;DG{P-*O)8D+~>IEG{Qex`#>%6%mhK4Isa>rlC0+)(Yb`(VxRddbXtzVWk z4~rFna~Gr?O5|oAO5}We6ckBujuctSUzq1qMunDbaVra3SJiz}Dau>uxS(Aw_Y z#FsArKCcbw`V7Vn$Nl->?w07iTb|2myts1Vn}bax!J{je&(P(Ct@itO zsYy{qrE`blZ`Zl$uG1LlzoG4ke94axyi9m|%xKZwRS1X60h6aV%2Ydzte|Xh3iENm z^1_jMicRaXbN7#BCf*VeJIRihmE&}@rQn*j?6>*>lJ^6rR(&H?yRW5ORK-qqV4RD& z`z3v#Sj9TYt!)WHvy?x(REDWU!+O!b?e6(f4jPHa1};=ddsn95H2;@v_rm`@(Zj32 z^J?u5i}@qWw~E=U0rh^+%=w6@DGp&;DlclF5bFiBU*ZLe{5(ZW@VGL9NaA}@A1bMU zO1Io@8QYu=soaNc<+1g6SLne_QAb~ud%se_0XGf=8l7KCWnLA*9F`abc zA`c#oovE`AL+=N6`%cdouh%X0;1?9bT1ZaCLwTX`AxjBycKseQEAVL#_R-|GY2?Go zcPMZyEgkhvBd(d98c-cy@Yk>%1tFZRoRSL89~vece)?7KIL{rCPJTK1L1Zy3U_L@t z(Fb)<-Zc0)>AtkP4drs&kX2m@Dy?o|g`gICRVqXO_;9jmO|z{3^-}E^YLbgwaVYl} zt^athF&c=K9v|!cuHH!(jnYSRf(?59kxI1rOSAmfQmp{xQ73U=!BMfH+W5f^u#w{Z za3(I_)t(O_rcXm@-G?6;X8lK3&ycIpx6x#wQP%dW)0dz7W z(&g#NU&1Bj=JESeK|i-QLI>7vj@K!y+&w#4X`Bx#EU7;CAZ-pZ`So`=p?z!A3Wym; z>#kNe!_zx(Dv~0BQTJ%e_>5?86Asp?{$&Fci>!{m;<&o4=Z9-Gow;Cf*Y5yxSVTR< zs^Pa`nZR@n{)m#i_B7JZ^ovsmv)leDY$A_oO#Pu9LdKOtSL+K~79VCmaT{1nX^WVV zAf3B`O7H zxIDNLNgr*7?1Jvo0&+VRI=y4}YHXbr1 z`Q=|N-M&W#p2-nSpKZf;*eKmQRNNu(@?(3uoI%XQonWklAQOQW>G`XzlZ7;J$wA#| zdnAq*JQ)|FF>KbrQRi8r&_LZi#Q94h%N~}9DHExL_I@W{=m0``qC&WH(lBMFqDu$OZ|^Pv%sumUlmw zkG+8N|=&wTkp%_Z0|eEVD6s-EW+ z<%a;*l>F_IhvP1XhBvM;vi@P0s&MDhXZKdT|G*w}Kt%!X011&tBJrR`(Ig)UZX`Ju*}MA%gC<2 z5$+~U+zmaHV7_$PEL6m#vW_12yt6PL>IVI@wW*tJ;>AR6Ir$bR9>5`3qXdB= za_4S_hk;3jpKLXyW}&@GWYJZ0;VpVqv+qi~XZ|k(y?(-F%uz-7*(COzw*efFfAs|) zk+BGgOj(jatMC!VEWrA$_tJeVXR8f1<^1ILE@iu0mtppuTQZ3?DFJ=ytN$mE8fqE-u3*G0JgyzGw@%i@ zy;nSlH}|!yTUhvKu&e-!;;y{(SMCaIc{Ysgj84o&c)E#*aU2jgW4VHDeq~(;R&^i7 z#1HqN`tFZV^v7SEyjF;1eO+gM>KAd>QC8~UIpGdXzi$)B{4=u!9-muWwcGgYrAt*o z|md?);z1e1m6gU(V4{kUids@~`r2sPsl?Hf7k=5VZNt>fZD^Z$1&KQDH!1kr2 z{dl>@1eG&pa&zjl=A;z1J&*=UD!0f7AEJlc%k*1Lu|^$^5ko}rAs(oWQ@ zH|a?jdN)LO{>zRZvHx@_=4$QQuI(!V>K$aVd6agLJ8Z7F=r;9W3`ao(M0Ua8LLem8 zUg%J<4pQTH$F3rn)&|CW!-eLd*fEYEhm}T}w`Jt!gi-&6?ILh1W|5XQ(jL?i z5jj%=h@$vg!dUq7cH|SBIjCym0)*jtr__t)ybB7a9UU@eq?YoDt8BxBg*xZG#^&(w zuF5$`zWBzXnlwl!V7HEvJ!ORBzNuUb+{#~b{CY=&w)N~n;a=9(J9*xv+h_^BXu{)% z6PCv`$@1#u?5#*dl?aQ1-Y?H{EHW`6Nfwk(Mm}NAPayW-K1PqHol?cqzi0nkK{d;o zK&F14E%uywwLgn4f!*T@5Apkn!;6yMQiPBE{@DVb%M1$LGs$&EKDzAt)sd2*W)aJK z5fq^q*1^4sk}AF`m8dvNgdEP-wF)2Od$-D!Wf~EVi9I!bddqNc+1aC@?C>s|XPST# z(yrA4ha?coL_Jj(i$Phbo(}P1zLrOaINlX>;7KhOKAN#M7j}Y(k7NPsiUbUX?eTbF zVc2G6_gGD6`bN(gQC)1G=yS7MU&bdABb-JK*X#!8achXsfEa2&b<^C2ebEgqu*$mB z!?%BpL<8*h$9A?eDvywAM5zUpx=Cz((ZeFdOaCp_O(3*v#1gOwz+~@huthj(wx~g- zYWuW87Gg|~CDDK3eME~Y8D9Nq1P9L=v-#)Yl0}$YKj*xo3JNXF*%aVx7ZpW6uIDrI zv1yLq{0yxqiKE>-dAmz2b%t<~oypsx0+gr_`sbZd$eG8pfAB0I7q4vt7rlr)InWw1 zK(zAgn@Cdgm`WP+OJ8PGbQHa2{H^$t=-=X$FWg8H;VYn5zD}g1Me>X19lOpQA%{+Z zSA4|M`H^h35DP&LbssIZ-Un|yx?%z}!a$o^TB`<2=g;UFm~N^!N5I}A>!op$Dk`ov zu3W)ttEgp4&GkKoxvbwm}*xL@B;CCs9 zjq1qh*!fx!7&mm&N1F}ML690GwT!aJu`@8ipB}G?`Hn`vAY~@JShlccrQ(}6;2+RdWNe|{5X;#0bQz~XJ?rGjprPZAI+YUqu#JhaSMt6j{T zY&11PY4jUpO7}_Z-Amp;DJO*@s8D zn~3!J3vFUgIt6R_AfMPU6RA98oSMeIF8;lMl($;tSY-56BT8WN-5D%-AsFN0Tek84!h0xTj+G$t zrY90%NUpy5LA6uL2U9R&=SOx{$pXT(fMK#2x%=DVN+rK*!pcO0r(61)8e8ffFWEyQ zb*`uk*GI)=@g1{$AlX3%&1YVm$+FYP5Z-OapxdQE%?tcsx$F|N`aw6juWq$$ULoJSRz^1nTZKq{xbG7B8tl%jTjPLhC4Hq`d(tx}aH zPIO(+uC9VH@1p3{9c1zNS4f0s>gK37VU&kEECp>Fdz`HKaiNfA-6G&N=nzmbBQ^V>Xi&%%^UgEfM6lq?AwFJK8kN>g1DeW{ z9xq*5%iKF%$b*{nYaK7;`?IpSx?rSr6C2L+D`lLyz@1}xZZX6-HrP4tG?$Rg4!3Pa zBQ#&!T1b;1e>alAWaZq$`bUrST$mP68e3FlWuXPmwjd_x@tN!%Vc-`BYDGTJ6g$cbuc$VxGd>WI#P)o_?Xyc8}G132KbgZ%eRL`9weGe7a73WFphQJ%JJgg+sH%A~ zTI9FeY)7uW0NEH1E;_p0aS+Lmh_Lpj9{PwL_ne#;U-<`#Y16#e;N+$s+fV6l3-g!# zqSI{06%6-z15;`Hjh3;hI5;|I!W4R{i&xFH_~;eR6UxjJFB-m9CbLrw);{RS2201b z=K}4NoiOW!7tkHfY|pnNxz6~DkQfGxC9ID^55Aw8Jahgaral5dzE+dwu>t< z*(8T7HfGR^QIQ5_X@>LPw`yFJ7n->s>x$>FmM2CR(>&BCklVBDk+9w!H-ML?XKu+%FN!63_#36{rbHg6%HidNZhDi_0UvtgB z1`5?YO76EhLx;GxWk2yUO38aK*tcxK->Qi$gQ8I}Aw|nH&g`xUeY`cfrPV{Jkr!&{ zOOVS&AF-U`#)5bU>>oP9T7=St>_j*iV(Q~=<))E+H0Qzt?L?2d$5{3U% zRG)eC^m;=O(+K6f`b}iO7w$n?c~slj*UXYl+;tZaO~^^ipN#q-`cL0X1Dx83T1Q8K zrF}O6U7WEwBP-`(YZH>cl`y{j?_|QPYq&4P76o?Oa)Lekw$fLX0X`FD|4!;S!VE{aWbndr5vz5z@Oa#LbQ*zkLrgS zhcdDPE|Y$gH+8(1`Fg^wZ>WPUy$4(89U5tF)nQ{9+p@{CZNuInE?2R&WG+~mtS{2N zXP1Hpc7JLr!+rK$-&GjH@Q`OnYiH&re0R^-MhurTz2Cp@nSL5RrZqZb7S>#;zS`gg zPldDWvph@`h@DohqY|SV=Npkj!Km)a=kP{m9)qwW5?^tihjqwKgVTnSwrqnP?ZHVn zu|}BBB)$6ZxKZKy{8cbahMynt)zEh{wc7RLd73|C3T~fi^qCem59X&_O<;~!)M}nPy!l^;+t={` literal 7462 zcmeHMS5%W*x8*1v5JW))gh=mQX^|2T3B9+_!GrWF1PIcic#zNq1f=)gL23wCDAJ|3 zM3i0=fe=av+??^h--kQS%N^r?_+Iw6_S$>wvDRE`&J|;1ph-u=MngeCL8q++Hld*S zqv4;A`WksfQ>;9kg5trxHdxgxIAn;ZDCCW=!DG1bBb%qLr<{N}cfm|~4_;P!10O`%1(D!i_ zG?~&GsWX``pD07SLme0zc4oyZs*uxH84qs1z_r**py8k1aEq% zyeP9(w6T3p1Tp!EI0nA)xD0L;%bg5b_0<9C{l~1@#eh{{F3&r8&Ok14CSOYCd|rO= z&TNKb!2R#kJm=Y2V9s3MtcvX=PFW_VQTs=_7Py2toJ1eRqZbTdJknTp@Zyni$XZD% z*G6WA1e+lF=s2#Tp6!d9tkB%i+siAti-^x8pa56&KdZgmMcWMTuZ=f;xpO`I&A%KB zvG3~A_M9f^VFxk{M*zTjRQLv?HcMPnlQo@4PjFh*&fkSW+tcwGYn65pp{5a_ttK{+ zA$nCX&`c%^1gTTD%g_suxcg|cuUeNQWbo>=Pv+73d+?@Ny}TlH!TsQOR(uH4C@F-I53<=t>n^IIJ)Z4c_c)AMa z^A8z_^0^H5-RU|*|81NrVET5bd^7Il$*G*FHYbIG8ojZW%;Q~UW#C}Nu(zD953kji z@ez&b9V%DNt3w^zKLN$bkI(K%ub~?~&i>d*j`pv9;u$a%=`^Q`nf_KL-T9Q>7(x7< z8PTt`FUFPb>4-0sUgX3`5VLe2wm&bm8OX>jV+(BTM^%9{$MZPh7r?uMBo!qy%1Fdh ze5{p-{dC}=Q3M+#d-Q^6s(ATQO;`hV3mDOmt$MAO(w>D?kTcqD zvh#CMjHQM`F3(0ucK$-syr65w*zZMm+N2_bvRnL!EgDsDhFGzS$XF+~@I#ek5?p=f zu5J=BAQfEjs_C>mzNwVWU{4hPK;Hk-K|Aeyx0vQ%0)$2y_Kw`EeI2WKFwESsM)seb-5Vr~2H9F99Hb(M6LESMrK`46X< zfvz>g-lstC50&c~(UalPd&{i?D&gAZAS)$fA0vq5hdqhBjg5ri`mS{^U$W8M>vAi80hpe^PTQk79FH?@vjY%LmxieWZYwB3~chH0AwH^fx8aspzt3%)9P{^tejo zCzW$^o5Vz&MHxW^`xSbTYsdx|OG_eO7B*%{JN0?21c@?}%<-z9J!;%fb_>dl2bgTJ zy7*jDdRVekJIZQ#0y|rW**G2r(c6rG)}x(i^t+%$cYVEa4o?8#c&XyMCbeVV6dnvH z|LQ@jM}Jh4og}9)g8^m*K&=BU({Zxquecye(%K_M3=3FS1O*Mxzh4Dvg0~$*4L<&A zn5Ns=$lQ+16s!0#JgJ@u!g-`|A(+O&Il$%FLX+w!76zY0_1q7*p3mm5A6p==;i_Zg z)98*#@l^EKgvjJ1_vk)JK?F}ne&q|^dgt-{ z>6y3LneFofhTUUjc1ip0jRS4FO~Tt8(QS{UnOX#YJeJqE zts=AfsGdKk8LWW!GP=e8m6cvn=E(tUuCr1N6L0Ks*5P>Ga}iq26EXb9)lc2&1_kpRUZ@^{O>0up|S|5>yLy{*6Xmz%J2r z=r8>hy%nf#Ly)wk_}LTVerlf1_hruEs655aC(e`CWMwkcoc6A|C9V$kuyUEXULHI3 z426gE;DAAJs)LU|G=sS!KC8DUq}CyNChF8Ed#OP(OR??~^r92heQBg}n}+EvR}sZg zHEhQaILMh_=({ZMDj+iWMpFBw*p$x(Nd@(xgXoAWnQBS#wtyavy4Kd`^9?28}iCUK zIbTVJxUQ?9e1w3vG*0uXFK{Cs=fyf*dUeA^c{Mq{2Q<0ewUae1zon0bk8yg5E(js% zD;Iw+ZkcWlro;>+sc1r@#xFLZD~kEQ_pEyxsd*C1%i~jSFw&?Dh-WzgB@bcBX^OVS z999z-cbG8^M$86AyC0)pk8+kzX%3~AT|VF%;#BQj%26~J)kBVHODIir9bnII>@P>u zd4*K48egd}4;9_CE{KvJGuueeCHq&W_fukLV@i9>6n=u&v&jkb%}ongGCfjMRFMOQ zoP2c5^L31P(TW?0qEVwikP~-t#27Rk+LTPJOUte*Tt?^%19N0S+Ct}3H~bfbt%Dsy z*O&zAqMChQC`BSx&!XF$>a(A>s4HX8p$5Yr`v;TeGMw_Z{BHzs%4;qIQK$93E|(`u z=Rr;tPbe9Dp%uJm`;e1M*27sV;EfVfZP>Bcqd0h1H~i1wy$s-q;N}*UOZQ8Pj?nGf zld{AMZUMw&4%OZRy-^i>lSHQ2DixE(2O0GVs`2lYUss9wE$M`Aj=%C4C;pPfnr?O+ zmE-rK5~5debu%frQ}7u#A{dZbQaGbb)Y|BV4SlnQn)F`wzPC}3hZggl)cg+bQhAi) zyl)%ubp;=$*39G5#B+wpp{r%j51%G*G|`}x0O2uQMbZM3^XXxp3Fz*24IT&lZoomw zR!`n;Pg|>@CX0kbmrtD-N4_Gw>sw6CKq=(&nXXl zGp*j&C4x#{Kb&fIJUE#tx^+iUy+W&AC+j$goh<}yLz1Z_Z@_0r)G1uy{YY>bB;xf zF@W!-q^~~l^v6jD5mVeQ=#4F=ht1Ye(jJb>2bdPre4dcXb&}-KT2dKv{!>P1=$*6B zyyQ2v{jcxc1Jh4{dRiuYLeUsl%q*QmM09R9Ttm*E{pxpEy^Ij7wGayLeDVW`+F~xF zvZ?8AA&qG z0OsA515ikz?8ilrs53m>z0hGiBoQ~tZC1Uk?#yJ z3Bbr-YD(tJvjiGo0yjHp1qgf20)to9q6yJ5pnLjvjmZekp*4bZZ5=qV++d`=*vcXo zhk`2354i{F7preMT7>Q9)cfp0Tgy(mB_hTPwbeWilOz`o=Q?hPh5XG` zPxuJ%fym)Cz)$K(MBdec>~pAq@d ze=G5F>81aIy9vHUXxk3Jc6$jV8m3m=SQ?+li+Ie?nCa!d(|&f6S#Ntc3E;KOu#U|x z-B~-Ff@XX^4Un_g;%6HL_-@To~#uLX*s#=2w%4#KNb zT0-0J-4J}kwc@mph1rJ_Z3$3au^yIs&8f~*(qj5jZRO?bO(mH3CvCDL;STCpn-<=) zNfA=761(}{&~=nrU)JIUY<59f@%&sM{ytfY2zkwNB)W{ScmIYy!8tii%n@>PKK3O< zucr@$k+?k&_Nw#~o?&X9VRJMmEOD3*+>@!qR)VFEVWC{>S*XSNTZjkZ$5XP+B((L) z$hQsT?$9A5?V`mA8LFJ+)bWW>a`$lQ;SOO?1xq%+eRFv!dl|P`eJ?O|pYiAD(sj?2 zGUhr{4IH^pIB(aX0+B0{HSx-*PYG<(yD;vLZ=y+a5F2s=TO)p`(@zLqWaXX*`Z{z8 z>5Rm7Ep{~EpS2!skWGlP$k6BUpAN|MJk|keakZn}@t3L@u*Bka!+bY0af8hUscFe_ zgUqiX4?#P3+j?UiL_g+#2)02kVGhBEEpf#YZw$&}w)_2_XZIZYEl-=|!SFi1W-e#E zyXo}1vFh}qCRyo=@p*Dm3V|hb^~qmjd^{R5c6C8@27C$PAtysyN4idCm$o?^-O4C} zdd+r2k87!~8#Z71L8w;X&j_@#UzPFklcIb;gzjd-#TI2zn9Ue z_~+(G`_==OFopxjY+cZ&=Os(6^|LVaIhasA?B$f;F@76V4z2B%>sW6L2z(E$pAqG( zlLL^>o?6MNqg4Eb+dRGEaB#>h_Mm&OY+opz3vyquYD&|^$4(?QnjN%cVh#)hA9m2< zTQzGy64eQQUO(8`p2W-2NA&f+?H2PSa>tY}fQv-`AM;b7;Q(pH=J!7(-WI zTe4mwOmL=JA_4QKT!Yf)I0XjWOLPT0MP03Z#v<^`QH(6EBX&JcfO|(&OG92JaZjlo z`Hhp^MvPFwI*WY<9AmY1Vf{j6S?9vv^k%<>QOLaOu>|Q?$l2OUaoU6*@tO2+EM6`Ow5GQMX%bVFTI91jES?R42DOGoWdS? zQ-W&5{?VU$7w2$e=;HwVwz~h&!-`~oH__I6e5o(u1c6iw}3RMXYWO>3I!s zTX$1{4y^fW4js{K=0k-ASrgFK8@U~%rIsDfYI~Zg;Cd^x{yZ^Tx#$Jmlyw%Lq#SpXVCDM z#=4)&xv>kfP&BDp|8M=)4^>eS)4< zI!Wd-!>^)FKmCdT%)NS+g4(z>h)WVO10Nxm&Y`wjOq7k+K+UF(k_%r3vS!To!lluM za_13%d5QvP|DvLyx$p~1-9sY%en&0|D2BfqY5CewvS)z@r-E7%)ZkP`uFlz%RO-)r zD7$y8_VLW+2-p2?D|!zTsfHa7w|~#3c2w8lh!|yJ0MGCdTVUrloC&)&=gzh>Vp3^I zd8wHyKUn-C3W;+TsgNKp2uGyl%gI%ddbv|qnEIxEL!^z*cDzeKEpeC@J=y~kX>`pC zj?AebEktgC$}KNJVyCUu3%}qI$Wiz1MWf_JDkybQrGYn zA|L@;F~g)a7kiG&B1n^aDd&t0=^6{FBB z0bNF}EOE4;7Z$_p(}Kl*uCr&UzSi=c&3C>SwxO z8G19$rld|gpK!IVVyn4*Ld;;{A1}+a`a2fC2_${f1Gwmxeaq=|b4rjt?nV$7{x*Fv zNw;(QYYyKR9_Cr;M%fsdspVBRj-Af;o~?EXY33=H-pN^8VI9pDIudY!0)AFWkZe6C z=e|mac7*OLG_SSe_`9iq?1o7NZl=)}PQ`-JOo?0&Wu~iQmW#~5AQz0bIQ!33ulrPV#vLx+1iaibYR8p`Y z(}c-|u}@jtv08Gm2B$6Q*=d5ymi#sZY5AIKl#M#`*mS}YXXV+UVjx(*b2d5T0*?m$LfqH>Ft;2nrx7?_w|>gz|mK7MYlI1jayrFJ}i znC!Hwj67(Y$Q?#oJ?}chrWb#Sc__V^lNEqQ@>sQ?roXEC@*vCw371F1tJN+uwy+@V zP>c^2{EB1HuF3Hr3o{6WAO(E7ZM>-+TMjx(E70R|flAHQ8(3$#rm|_?Dv3t^WgFj~ zMXwDi^sn+i$huuQey5S0YSA~8FO>8&TSWpxau``}-+I4pD5r;ea1VYUY||!4lCNS= ztuaVXTFFZ%NQxy%0Bj}D zYpq}_m8^;-4lB5R(U!-UHuhFy-4CulPH@1*e_0+5>Axe%i|A^1m`dO zCKed^vh52t_)s?k-rGDjJ({z-CDRcaxQtzYm=M!O9 zeLyef^)$}Qb7y|`9%*IWGB;1kBtyR`h8G>h*rl<(f2$Yyny_GTeAhGNbIds?S_;6X zU_UP^+P3tIPy2^;{ky?m6sq)8Md(g0viP<*+kX*WPp-W#xd3Syj?hS!D{~=JKl}al zb3D7dTLM2?3HB=7hCK43;!zA4OTAL}p?g=-Z7!D<MMEvNujN70IqxbEb@N - + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 4c27a15d4df3cb7066744c76fad94695e4591aa9..ee6d88cc2c7eaaf50445c3b176cb29eeb98b86e4 100644 GIT binary patch delta 932 zcmV;V16%y02f7E4B!7WPL_t(|ob8#*Z%k1bz`yUFJJT7a#-j^^R7a(y=%QE2iHfFP$5vx~j0V2a zhcQLj6^fZBt8i73b@rKPAIxgPWpe{(Hi!@7=~ItYWmnX%O@WDk5DGXnMS_`$lBr9s z$C%%cDQ2uHVt>0zt!r&G=x(LLc_H)28vgX7c-1ef7^z}A5>@us4I~KG4X-7*cDI+1X4&i%o~&N&7jGaveC91R@%9ADA1NV%Cjz-t>t& zhuj8==DyUKr_?hlu^6duEXnh*`a#}-xzJ-Q4~Tv}vwyHe&L&AH6yx{1Y3)MMmA$pM zjouFPhwte9h`Q_w#YAJTXmtJ=@}fSCDGA#uIky5FrjuQW09 zcoi;=q|d$h`K|REydi-I#?eZ9?A3KRTOkBaZ+`?!q%SthsYgmL_Mza$MOYZb1z{Ta z`e&eHOoY&!|BbGuszEWL3HF3>xeWl{@|^#t=QPI#7tWJRu>IT z_kW5EqXleVIjf12AbwZcsx3{7ddK;dR`Wy^tB$kkI89p9JvI#6SJeb0Zg%GPwjGf< zdGIGaG%{%(T8$-947uo;ddIDso#JJVB`z-{&x)d@Kh@F?MPXbJhH2gaj;84IgxoTe zP4|P}++IUVXNIj~^3Y;_LyP$hE#^10m>u8HVt(Vl75ff{Y&RlEcqk750000ToDigQg$kuLE%_tSCDPU_B;L}gk6ip}*m(OKY3n|t4 zN5z1n{}+=@PdYIkcE(IYsaXFA5y4l8KX}Qp`K|1e@Pc`}lpvl*pDY_vjsZ zJ)p1|rB^H_lCb*8#BJ9fVrMJ~#N@{SKSMf?2{ej{OMmTDo=Y8l6GPO|E3S3Q&d>U) zYZQ|$U6VbGfh)bnh`!Qx#tg)YnT8cJ4J(!)tZ25=uvT1sDK0*bUwB6e+fw8Q@D4zeJp_B z)R(cXMSuRDt1%)UebCf^&b?w&6ajpzyEp@2l|7v;lmp(-D#2`CJkx6AK+&0rN84V|T$D z|H4w-hyq8xyBz(wYKw2hFm${MhF$l>eI@-U6Mw^%^B^;XS!vYsZDLeh>!i;k;7R%5 zef<1%2P@_%eDnk{dJm&m>uTZi}|{I64JZep{gHc zVt@30NStcr2P*mc#jH9X6L={^fCvQQ>|=^5+v%%ZJjCrc)HlNGN?BpnSi94J0O3`? zcmAn-sB%NBcvY0G-Tqt2S*e(l4s3cky~ETuqP7if%l*K28VKSCQ@_F9$4tYDnT8cJ k4J&3ER?IZ4m}&d~Mr$%|yhj9&00000Ne4wvM6N<$f^7%BXaE2J diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index acac2238a12bd17eb73ea96be84158fd1ac5bebf..a21d95aee713b9800f65dd47e19396cafd12c853 100644 GIT binary patch delta 622 zcmV-!0+IcT1&0NYB!5mxL_t(|obA@nYZE~f2k(}5+Yheka(-4NI-)0 znDEe1B~JRW5-B4v=kI3FKnh z0Y^0R0H(SiMJDCQf;i`WMdU}s`tVgYJetF{nh z$%uGX0D<0u$i8lNAug9z@lCN+TmN3=;t4rj!ug7~AfSX0Vzt?Nr*n`*M9aHiz0)xW zOEqynhaZ+r+JENC+JhW^`RzFfB#N;a^R_A%kMg!(-tM*kcS8^jigwZ#NZg&muNyW+ zjL&0X#W#?A_ib~jRFkEeZy+xcfXvcCAaZb*nmNTsUt|79qezeJQLdcinOBbQnsX2q ztGG~6(+NJDUDu=svG5ZYZ4YP{K)@v*nN-t*>h%Py{C@$3>Y7g#esdDmgdZRPSQGq4 zR!t}9(H?y)%`Edw7V0hklUxU>x02%F^!iAwYkWAXFA{*hHmkhRNsslg^KpLZxwjw^ z36s9=4W+uquTB`W_U$0iFc70po{J!_V;{r?Y*(!`l8^$LwEOY54U)MMI~^5I3(d#H zAR&dNqG!z`$}5n#m!qS}uG=X&^<7^8!!SaR_rlID?b-{^LAdyfUmj=Y`pKc)!2a_O zk0{?SiLtzc3F+ieW3j5;dF$XoE9rn+=5Yd$Sptw*0+3n$0^iBxHm9I+^8f$<07*qo IM6N<$g2u}+4gdfE delta 626 zcmV-&0*(EL1&alcB!5y#L_t(|obA@nYZE~f2kv`2(JT0En~gbz&VvfL#cQ zH)$vvjmp{-*4rswPJu+1LAu7ScFUO(j?FcwB8PS=*N)5a4E`v(45EY(Vm>Rf`dEy0 z5dQr&S!Qiq_J2UxMe!((U-KrO)Vz3-z}W@&K@!DK(!8w1#j`J#n>#)B|DGVUGp5=& z0*MD7ac0d>#BdrX3!Z`Gr_B1L+>*>Kc?NPLKFBOB1R|XQHF}y4#&F_Sb&(#~rQAHl zBkwT%d$U0}UBYh#H4^60kyTBH66wKY9;ZvT+d3E-mw)_rOpS!q{xB3*2m#vPgQ)_) z69X>200F>?-~*#-ButNXR6a|I93L2kW#OcyWsrLJjCh)m)yq)X;L(xFOA?hAR(oqB z$8_aNH^2VcT@cw9fZf`frLw_qy{%KJYh-`GYX25E_J$;FP3}*iH z`VI@cQEQ#1vc4e3bFPDo$8mMAcia6wIi3aapSuqQ+NHyG#klw0brAk6@*BhK@?jEc z2Zhxyl2U${6+=m!+k`7D0A?1AhhsK2Hj@@OWiH1DnZ*a0#Rr+?4}x6dWQU6uIsgCw M07*qoM6N<$f)TtTY5)KL diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 8d1697a59443644e91164f0ee4133c712c4d9ac3..382e5cbba7c0648eaa36480951cb4a06ab79c1ae 100644 GIT binary patch delta 1269 zcmVHu;b5|OVSe;`2!BnW{7A&?-1 z+!DkOm$E!A24>Dy0GtoS1*${YB$ou` zBqRud1Rc8)@d))%b^AHweK@DGfp)@W0~k0 zx8A?(V|1G+69jiff1H|iD8lkIwq~boc5f*#fcl8P(QTse5`)xxvb3jzXZ+QBC`lub zAOsSG;ESMuX0%12q|kcjqPVt@WoNu%kAA9AE}LdYAB!bi3NbBy2;xLl&7Uup{|r9&Ql+BjpeB7Wr{OE@pLNWc1b9ZJ#&BnUyBpwty7kz%xFvwYp3g0(Hm z1c|OOUQ#G7CeLNgwf4tD+M^Zrm!m$0wgq*92ImrkJ!mug?1+7K#FvP+hjI{-1sH!& zBM8cO8*NcJ9W&n^V9REvmO)*A^**TFYrb(_jxPEbV+u8bc+(va2|^@jv^SgWz3Ci5 zzHYQe;gGZUMT_pY{y&1m*OT&iB@_jDO*qimoM_#~ku!E$?oZm0H`heZgpyFz2$J)0 z^Yz}qnPwIVLRHx4SRY6Kz_HU7<#m54J63D2n29L~RaugjvoW)+SAX*%*cIT_VWTA~ zF3u!>0)y+PkMi1eJ8{$M>=%p1)CC=Edlu+16)tIF2AM70iYm)9O4ghO^xbk|pZrL2_)-YVR}Jqbz?E*!*(E zv^x5g+wnt?_V}IrScU!BHF04kU7O@M($~k)Esh;tf17r)L0-CUeKO=t;3>Qp^jsZ8 zg6x^v41yd-V~b{Izj3;W0khs35JSk=(VjlY4;IOqF#B}a(`O#uUt;TUx9tQoIkNOuOHicE=UY5Xf;KkYubb5cDFl6b@`D}RvdH&koPl5?*)B3 z#hWvA z%*e&n9dzZa`wy4$~LiWf0%i?0(!`DemDH^I)Sxx2hGXeN!3 fupN=O4Hobhrh=cZ?)U_k00000NkvXXu0mjfA(~_y delta 1239 zcmV;|1StF43cw1GB!34Ov*CYpF-;vZl{Xt$Lp*Tze|HE`AVnbg3A>=KG0jSaCxYFn_Sw6%NDyc zbB>war60=fi{05dYkZ%J*<|+Q^p_{+yk~ZDPIX4)MOPi5)Bz!ptsavt1q**DEkS(G zR>nc}-(XU8lzWtrApT5)@xfju!Tju!ID1`noGc|I2pXyw0|tx*DpY4#rGx~PCL{=f z1Rv;E-Lj+~^MOT{SOOK&n9qBnW{7A$TDOY$=%pcPf8S_IHV7k_j)~ z%X%S5j3(IBk_?oKpKcc3n;e{xFSW3wDTn9XNQdCb6ib?^_xhO5*|J@DXX5AS)SKTp z!^aYCghTME2HBF?F4)PEa<3~4D$)of2tnNoGc!F+ob@elJ5w-2Lte`k6J>(zuZCH5 znH-o#nteIMBTaViuvZtk{*E$1VqjWeb{s*ySMA=bo+P?IRD+Wp1sH#V8bLhd*P~(i z`+|A0pWR92X$e(6{pdbutTub%awOql#w=8 z67cmzQl014Hds4$YszeNW$!lITfecw{p|5(-l*0#|wW!+N*8a3y)Z*uZZ&# zx!TCFgfrKo;f$jP89TgVi}v{NE_5vXouVK2uhEmd8<3FAKk|+ zX`Q@)jvf9=D{roWZPovILD`MI21gr;-{w>AeE*;)#W<{9i;4Ogw2qQFN4G+W2`W8E z5Q0~N+&*#uTDpIz6@L#^@deHQ$pYohaZ8`(+O`z{*uE*<`La75=BDU^#4od2$PYeE zJJf9Vjk?k&4{w7_=_jV4SywvDL-7TjxyD~^0RZi-b~(8yF8@(rhkR$9{%%Kl&Px02 zHJ4)Mr1*la-xhsi{KXwmX&5~bJ2v4Qi+7F?{QIg@uJM1T8~CB^&W|<8ME|51oGD7^ zHF!~^tYZ_PpovGCHdeBwT&eX^{Cdm$^peX#?}sO=m?g{;=k?bf=Z9ON zT!)RWXz5wSeTU?x!dEfK9 zi?Y%%xVC?YJgdj)qUC_=M@CrHeI}rH*5{ z#METT(c)kZGezbLb2+D^jSLyFar(YL&eNaYALn^~@B9AqKJOpz^SsY{H8=>l8>9yU z006uFkNJdrt;PQhpu*RjXi8cH08|+MK1ahcD;DTtV%Uhz%h9n-UULccbmFRlLVm8- z8EYV-YDxQ7>q)Cni{j{HQ?0eU_g zRqZXGt&X}N@S(*zH9;7}%0_B5r4U}F`5axIf?Q6nnGF|{%K+-4I-@w*-7nX#G(nV~ z{;Ba=>f}kosE#kEkkib^{61`02G$ljaSNxd=C@y}53YvgUG4JH0B)ukchdeg(`a82 z%xEkJd+aW=Yx{mz9W)6)wmv<6&K02^@hTV&16v>06rbbvX_|zH$Aggc!Yy0c*KQ2$~uE=kfM6K%th$e7E{P#OyGHK=|kT<2k-ySl}h{Y@VZgl zm0|0F@|4*reO(DCE43jbTagZ@u87?<+Ck3&G6@;U6M;z2mN zf{<}{*VuKI*_lfaQ0L<3T zaIl6Fvv~um)^j&?25-f~^*^oFl6f*u-61L8Uwow4Jeo;g3tXIR6-^#)Z4(IU8@4V^ zb(a%6reQqS%{fcUYD4JAAax{8;N0hR|L(wvQMADqXtw1%=c zP>qp4#wMy-=B1Vqp6X6)R!cXICai4V)>r>nf~vX9I_-#QB5*heib>4PP8^LS_(7e& zj{DTM^g+E;H{%P`E*9J6{L|6@&D5OZ>f|6epUZ{B_U1Nta;qh{|MtdK3<_!Fl_xq>AYs) zFqf_#JUjV*$eoZN<3%gDq0P6}ML(Oqr^>y5 z5E=1&32&TpSak=*5p|4!0l~BWO1!-5Cgswb){!F>TJh=Wf1>Wjskk*(7BTOHM8jT4 zPJiU^dBU_N+uF3wxfNS%JK&ZpEAjdoK4zm-|FQ2HHF`&AAFC9gRGC+E4Wjl*)$KN; z0V$!(b0dYW&abl%$;UqJm2PMK9_V(`f_)5)jBY|^VGY)a^QcHSRf||ZJVxm5_76hmAFK zB~d}HznpCg4Y#_--s^;sWJR2R#?1LQDF0gLJo<*Mhfs*UmW*EaV4fUt{ zmZwM;LYk^NAF&3=wP#;OTy#EB(v!9mQbn;*1uEiR9f2@W)^#4L_(P?vx(N4MmVXha_YljXngc w&^-iR1rI&s>uYzbK?z>U8a?2DNYVC=fyc;`*$ab~UmrKX-#5so(F>pZ7w~$sAOHXW literal 2034 zcmbtVdpy$%8=vyd?JQk2ytP6)mFtS+l4MA%mARcpesY^buDNtJTWyg`@=Auv7&5ue z-IT=dH1m`dL z>hs^%##64O)&v@7ndCmvcG@4MV;cV9bvy$)3Dee=G}=a9?Y&Q0OQn4d~XWRY= z(%cUTBi9VW>ROH6iRU=DF|px291f$JaWJ}%DTox~H@RaGsPQa9lLc=qQhL?T{JqoWz8dkcKW>B8=v*@(M*%YOf zciC~pp_S?lx=MMnR(CTqC47YNiM3&0ov>P#>@vtzJ^+&OUUS)H9Q@{nd}33nDNCVq zKgvKyB%8b+#Sn-8Vf-n5s9wco^&sx+?6?JllQk)%qAlq7{GG$5+?hZWs*PdlDazpF zYuJEh`>k^<*Uj!&v3ag#Rh2fNdgig(`A`D2RJ7Nz;s?Ys#2*$uTC;7pOuKqejk1v8 zI4Mz+N!iIpO;I0$4i)ZJb3s}jvTJpABHQ+$4Q59FXB%|D#7{w9-~2mkJB8GXYnBSZ z{{o6jTLZ~iwWHrSS5sDIQUWF-LuhAd4sLf_daR_DbGL{(m#kR{2sr;Q2`A)= zDX!T9f-J?$4`vy>iC3A2FqR#3k^P*dV~O6&g}JiKc9*KcrW=hCS5@v>sK{V9@?>0 zT3BrV+LBh#%j$+j;WSMZsZHz6Mbq8L(w1=l@NEtY;;Ppc98#BZPqgxYXu}_a`Jm&G zwX$GjJGr?zn%zuS<+%0zGeSh$F7hJVvzvj%?Meb)&kfhYv5|!Rt{=`JMp@u#I?3+Z z%hwgV(Bj4kx*O6*=1h&{d;@N}Sy=8?EvqC5K#67U43B_KdHI48IlG4Ck2Z~tHv+H3 zl)v?Y2r!noHfg)KabF9@qwRccfaKBk`WxFnivsc&%DZMyv2_d`{3A0Y?`qbyeRDai z@#xk40iE%QhER}iv&2csX;!L|eXUkC^ji8RM>rd-t0C^D-mX=_G2R6SR9QKDVa(z_ z#m>A*=k)L4^O0dcR=+-|gAa}185+TJEJN@CTtd({8BWL2_mc$|Wpr?#prC9y&SlSF zm4kaI$&@r8Hj2q?R8=XShov=>cU_YeG)p}Ft`g%srAhbK2`5S^KQ>(@&VV1w^L_5& zF-HAn2Fn%dg9*xaBaja814LS5Z(kfIe;JB(_6qFX2&wDcC^#AmpCANL4adi!tRo8N z23?RA!l9DQyY9<7zM~+Cr|cBNr$`q7+Hl^3$0zrc7Vd-hT0|JcMju*|RVk*`=)^FM z-}3HiJtP7Grm%G*Z##Fynp$~wg?ikI!N}iRq32uxJbUu*C%L3v4u$cj*Y5ptOD+L@ zaxNDbe5v6GEaH3eXsUf4B9Q{`02K0IqTXdVL$mamw)vHu;HHq7o(zmM&i6qK(13Fv zy@?dT$DC7M5t4c@X&IXqr%Dl?(o(b7Nhrb2ck9q`+g(yU?ATYB0WO&SYerR8dgnYZ?ThSaW z3HPq#mYmAjs+AX-&r|Ce*lSJtR_|ksOQIb3y^{-TG$HfX^X=Y#YC}U`I8|tv$J1}S z!=x z$D~sOZPwpbKR`_8+S`Q_uppXbJk$Xot~xE*(Ib1%3}IL}YU>(Lk4p{ojuE>o4p9@o z@O_2L3m1Y6w4c5LxEuV;)AWV~s7%a6c=T>uzdu)xpEF1n;j^4!c`Rh{_A8($b(x?L<`j!}Jbb%e; hYX3EH`;S6O^aIY=sNo6d;Vmr(+1p$~RGtS&zX8ty?iT<6 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b7cbde6ea3047bf2d867c5b8d89a011b8cb1fb9c..6f0aa29e9ea3eb4435fc5f5a7d73fadbe4ded5e3 100644 GIT binary patch delta 2493 zcmV;u2}1Vk75NpAB!9q3L_t(|obBCxY*qCg$MNs)_dDm_KGE`EI|}8&2#Ql+Qz#FG z>YVJaEt|>W{?RPkO!ky)I*o3C%;?Mz6q#ExiA(m!wwUdYjbw{4ZuF=ls6Q>80h~wz{t0WADA^d|r(SN8$MEJ#Tu?Jq*9|;1Ga-lkWjQaYK>N zNRj^;k^c=&`dE6&RCCV^Qp|_DyuZCip3V1&3K0Sto>?@-JibUN6)fC2g(?!>?+@gA zP=yEq4Nrez`n7t9NReJ%M+j&*2D9KqYbMrQm!iJN;3Kf4rKp+y+fYi+s)eWWm zb_ZWfNmu1+nqQ;R*6wc)X>`RTb^$3@JC85c3nsH7>h=8%yZxq}}7wfoNG zdqkm$PC%-<%z37f#wCKo%=gT2YAez!w=j=-nk!7scjl`v%?#!`YRa884c?R6?7@zF z4=Nnd2}s{wO%-ltl0p&joQY~?B_Hk1_qeEU26t-ql$k7EUWb4Rj+#_@{aVt#4aKE} zay6hDbX{UaB5y+pTXX_qM~&cjLCB&L5ab1sz!iTaIsvJ!9%jxu7|qHrjG3^Z(tf^2 z5Sr)&*V+v1n0^+0R{a5yzZ_Q;_(b=os zhTYs#5T|o6k+-SES-mtk&xFYS`y=o55A!{!&_pL7BJy7Qz}t3QjZbph<$}5|xIJ|- z^}~NRf`HPNI`q4RmzTj%IwT#s;wAUQW2Wu8lm_F)YPUPyqyvzvt$pn z^X1VWc(glC>HB79W^kFAceRB#(BcrgfNVqi<@Bm8=Du5~!le@zy}!IGS)2FAgXTg? z&#xxScI$uq?I*$;X>o{MKtV(AciDA2Ls}R6n-BY&4~Mh1A{57fAViaa3O;`luYgp- zF%Qkxch&LXbKbA_a>wOxRy(QG`TiYdUbVmDRQlz;bhRLEtv5E}6_E4D0=;-j@XWWl z)rl9sm|ovx_e-BXX=Y6@KU<yHmVBnH=h7)hJ5Mb3 z*X{K8kES7W(*(16>GgUt>m=e-jINN6Q$Uwe{ErXmYqPU&r=iMSXH5e?v)#7*fA3{M zJc(kGjr4}A!Dp`h9esPvv91RJ_3^Fu(jGVI zs6{!S?LQVX9qJ6~>5Y3m2&kWL)9ahuM;5Avy8n?7v)y;bUmMfUTtgwCe!lfyY)%+O z0~4-81n*p}ogRPc$r6Qt%)(l|td7&x{_B{1w{z6%Vc+&X-<16ELRwxoY(u=iLw>AT zPp(j3zDZXl?K@}fKaa6rKu=a2g`yA0ESv5;x*+q!0dw!n^s24)osQA3&gayxn%$(K z`r4d*YG!+=(-ZIasBua6xx2YCp_J0g>g=*IbXGI?nmFIj5Ll5DMM#}fsH_=1$ z^okpUeBM0a{kA2XPweAzhv#V4iV#rr1oQTrTIkh7k zKm-AS2m+JG3J!mXT0mS{*x!-SIgbiwD)y`B3;X3BiDTU~YvRC?o>r-7@CnU@L)TX1 z%=2IBE$VrUNCeb&C3{I#m#L}a3iRS)t<;?Btp}MOyHLovk3`W2>a2#r}czqOKg>6`VHAJq6*Kkb_wDIRc^=pxt~TGC zn|+RvVzNzbc^y*-iawxVH+1W9ec#Ok+eT^SJh9k&a;v>mFjJw9Nj$tj)m3EwJNvY(0}8gpYe)1~XOrpw%dM2UvC@71p488`@tJULVkhDn_py3aKlqXl`}Ys> zH>S`;BA|b>SL~k-=?8DidV^3jxjgaGz5eF+{nw9hSI^jwD%d+t8q-i`?!Q${F3)8Mwu(;s zFDTIoDA+(}ucp^*bG|)K-8F4Q7b%aS;6^Y<&ilXF8?hU>Mk=6SJ6-5aZ)`Sio-p72 zT;_k^&=~*d88V>0uL~*v)mHzFBgH=RHB{6B%J6r$**$G~ZjEY~s&1=Rb>o6NB8+87 znSG%1J>1%9_nfwGo#d<0ytE^l0cCEX;U8$H%uO@3Qa6q#SC3somZ|e9pB=-`eR&eu zfO1&o54-X`h-ivOK=7$SKp=vEKm-AS2m+IQ3R#iQ1{UPMY}Zn|nBKo^00000NkvXX Hu0mjf-moGf delta 2466 zcmV;T30?O273&p{B!99=L_t(|obBCha23@Z$MOF;yLFRj1>Nf}^0dozYr5iUk=vI(^Y<$LXtfEM=y(4?x5RNJ3E2@c~dm z5Q1U?N=UN1=ik}f5bq5Ek^h>#yZig_Y9VY6?3dlWcRAH<>5wsT(#T|HG1@u2TNy4!C*ddYjUuC2E2Sw&jZ&Q)w#cQ*My*?P%)vwnDuDz<`?nj$r8vUO{t_vbgm z9B#o&T|h51y=bCJHh)V$Q~KfTus`caQ)ger5a?_aH3>eTT8_qlzh zvnT2ig(f)xsoHY;S6}C1J9t8I^DO(aGWQp)1@bT6zN1OC)CX^YR~Fm%HGB8&Fz4!%nmnJVfCBv<_ZL<@qxjhAsD}`~#03Ns3ju)$0s;{P1R@9s zL=X^&ARrm$u`5=kO!@-q&3_-yH=?CgKlP|0GrFbvJ;~7GD0Knx%SZUTSE-`mKR7e* zJ}L>wY&)cHYaDrk%j~~+IK-jGLh1rC$38#v*0<_!Hd02@v3~EbZ%g*(J+sd~F+f*V zk?WhCN6hQT!y9Qlq%NSKq5ZvPV_P`;@{RZRcf;9LJf$%p2oaIdK7XVuAYJNMKf7Kn zt>t%n{S9rV=d)0Dt7cR*tGAS!VcyyQ@4mr*bhm%e=YJu)saCI9JfiATsQ2>aexaD`w<1R2tk5(XU_eJHEIfdr_^mZYfz~&pq7QFfe?X@@XBd?my3th|XrW$MQlCkwLn;nN!SWhYg$}Z&d zM2E^~)m)#qynk4+?W}Kh&woE;FRan)myJ`;uA|-)X{;x$0THn=-i9`ZiCXIN&O^4g z*DmqyfAQSkVO>$B*NlD5#mw~55pPpF`)RBvy#WQs-Z$=s-I<~5c~$=XP+7))aK)G} zR#hd|gDX@?-d|Tx&$cdia|fG3`BVx>8>a30c9IHc9Yv=t(tk^hJt#wb$L(hK|NUr=qzS$^B&4rI=CM?PeV+>xd-*+ zNJ2o?O@Fg=OC1e({tNG$*8~gg2IrwR$0xO-{@n8rGjnUV{K}=TsM5DKs>&kM*<-dI zi2LjR*au{NW48U%>$1mNx72(0@ATjJa*svx1kXC!9gArBjJ$%fuerqU_vq>}`{5f^ z#tL59ExcS;m$`q~E7@V>Vjqyseadlj=IJBXGG^X($lKf*!j-F~ zP6c^V_S2l{rUpVl5zAP)TU=2j-z5+E?qj<$$LG_xX7hm0|2gD6`$jmg*o*Q;OC_=Y z`;Yw};HzSI=ufZHU*06oke~K9doBP0UC^K(^#}?bk+LVrLLSZx)sc1J$N$G0fiv;0r_o5_4mFu`d`2lDQoo{^W>hW$2AUn<;>i99g4}n z;3URah#)pK*_ zZK;L|=QlTc54~&-M}ISC?Nqck+-qk zd1wWHO`nu|z($VWtV%tz^5W;6hAEI?O62=4&AeZLt0+A5F{s{;P{*xbnkDqf4o55GL!VgCi6vt`=0h5 z|EW&?s6Xnlgeg%01;?r1b)W3e|NFqceLl^9nX2eK*OG}F(D0A0Q~n?K`JK`=sS8lj z0?IOPpK$Nqt{SVX=6W@6Dm6?}B@T_foBc|0Fa`0*XZ-dj{wqiLwd28JLP?a&fJUA) z?e8`FdntP_UFuLx*~ESOevdysLvHdXbtE>RJWPDrr2~CYk0XK75fFT35D - #1a1a2e + #000000 \ No newline at end of file diff --git a/icon_android.png b/icon_android.png new file mode 100644 index 0000000000000000000000000000000000000000..f8cc93387fa4ebe59fefae57422c369f0550d71c GIT binary patch literal 25370 zcmeFZ_g7PS_%L`;M^sb<6qGt3MYj8RPj09hJH0$*nj6~F|EExhUsVJ-_2grn z_CMKg$Ww>(L=u?h##eD_POa`S-uWM5y`>zwDrv+y`yW47RlWOHd*`0h@1TgJZ*S-S zQY$J77@PA8a6Mj#Lqzynmj6EQZCb_c!m=ta&V(E6o*@o1bX=&7ThLn_LG5+5f%)F#f5= zuIOKxOYGl$7TvNmeb@?58WUcXVCKF&(7hb2i_|lVt$9xdNEmxlER!%+C{V1*4f|Bv z@Tt5ZAC6P-;EhaFw$a_LDiu8T*D?9xS&i{Bil@FLLQT_@9T~~w@;o0;WH_~YhQ8}vVoGe|SMm#bO(uA0T@AptXLa{@g zc$q@9&5TKQFk_(2 zB=S!ZwN#7fIrl(!Af`eJ_IJ5M8)Pr$DwQ4qZGXMGLbkp2Qi$8I*iW#f*7D|P$gt;1 zNm+d{(Dv;c2MY;1B=vBik;zP%)-+hvSh2(-{s8Gl;;hGw+gI`#dnFP8ph+Sd>3aTz z1P=hTS^xLt|2ZP~5e3PDuB#~-a451%Rt_r``LOLS`b0ajc%9{c?EHnCk+4WgeX89|s3ABOf_Zvcb0Nz9bspp7?BY6@vc3_X(D*IGO*^O16jxme0)(x^i5 zRVP@$*sb4h=a1|mhF!P?-t}~J6qV@3fQMFGZDu_7lKK*BJ{#irer#TL%ru;Q^VwW( zWQ?EKs5qkFjxh_HJkVLSGP!gUz39sj*8%rc8S%cG%l$+Jw9#D&s6O=?haQ~?EDo`U z3f}fPO*6B_gN_J2pG{#3e0FQFFL4X+=+<~!P-i`Z*rSW1G*vasXaUDgTR@cW_&@Q` z!r<7mz4BFN4;+()ueboMRiw%C;i?Nqylo#Nt$@;*bikYR%Z=DImiJ~C7(s{1cq)vd zDmC!?lkQV8AYT#W=?m|@zNZ6s^l6fhM-N2b(uq>gsJ8c$ijt zrC9uIFe&tmecjb!yzjzId_97=h(Buz<{aWzVH{#E#F))`t?W!D7I8%9fWqEiTJ0J^ z#(L`KnNKPbDpw-!_6aq7Ho%Omf!L}Rz@4dKDvx@cc`S2vn%N$H6?zl=a1N0DsHoNTz2G+?U~7=T^33^S~#A$vXwLVx00(m-U|<3R3v0*Tv1H z12K^AITQm~OGR~%ho4sEIbzgDWc5rZ_H|nG0>;w(6ZE7*$DWN`9P0?13c+8m$}RuiYtjz)=AZ^4$Lv7%6v7T~5g^#hpA2}>F)81hxEhpJEC@vok# z<(Z2!K~B{o9vv98Y-ucP(V?=R%#V#y7p)cfOfxehw%MCiB0_TFM;Ge4HT8I1Zbu1& znFo0VvdJBXNRPX6F&@Lc9_ydzoRnNl>&n9Yq!-CXK9`oHzT_{RmH8w+>wT`?!8s(K zs>$p)JR-9d$R~R}Xz0V0kUr8qSJLU}(I|6d$PM$#Uc*iC*SSBZY|;r2AA-`iE*EpG z*LTi)JMcOkP_i&#OLq!@ilGN2pS%#`w9aFxnmaBjn3!y;yd(cKbTntqN4kciSXcnH z37|Py!5pT@RgD~Ac6OHfHo#>x>iaAgdH*Got5`)|w*M;MRt2)e0w+YtgDh|K+!5B; zD{UM!sD<)x6-B}#x^4u)KVLzJ%xyP=3RI7D5dCG1MN7laB92Pe&7Q*^IE2)trF>Tm zFY$Dz0cI(BEspbnw%3@?Cw-u>c`4!-bZIf}&-Sc(4NSp9wbaP3U-xX|cZGIThjkh5 z0SxV5{)fZ1FQSbP@nQ%FjYc7Oxp2MyW(G4&Xuj-om5=R)tZ`&*%I-ARgJS7BPA%~V zYkftI=(q9Dn%Q~hdmCks=e}@QfX+$`LRt;jTC!6;fFJqKE#Co`k!_)<#o6CU2Yidw*1NihG4DE{kNr ziN=9Twbdv&lfVnOT8YLl0qXc%;ak_V->|m_3@8Ys{HK)wtr2DrLcjpCqU=N!&v(#~2(@$GYqtCk{k(Y;+C+jnn zcUY>_-rc=1^|_Ye@T;SdQp>u=qT_bjq)5gQn^j7?=P8kQO7F5q$*;>|Uv6hIcg@-M z0(h%KQq&TC=!-j90w{h0wj0j#p=nn=WxLF9*WWVCK0IAMw|L73U$wp^D!tdTljYN^ zwM|>if24#zPyVDWkvLYR{xP^97*u%DYQ|beLlJ6f^>-~oL0;2%3TwZcmh_Bx{eRC0acQ^D0%rU92umoGX5<@)62M^boL^NZIl?KC2VsJ9w_UdyCuaSd7*Cq~-I&lg@^#ev#XxutC{{h84 z-4{Bm_~69^`1*7PU(g3q0Pr@=cG^2%6R}NpxE}yZR!~3PD!f>GP_712=qJR_`;|8; z+l~}M?@E8WSLyp0L%%5Fy8y2NC)wj|=QiLrt`v8la0= z@Y}WNs`(f0H-ZE|k`93zD5EY8t1+G|#^{q(#=?;MlMoEXPcR@)4GX2%GfZeUh1}UN z0*#eowWng>i%d3sct~Qh37+Hc)tg3)YL+TSvBy9(al}UmcjFhV6SIRqIn?Vh*a551#n;D z?;Am=(BQzHe_;Le*>#*~$E>t*IlnLLEb|6CAKRDf z#tc`#sw{wq3YX}uH6HMH91gk|g&8KWDM#EmL=hZrLC$7o9_Ql&7vmVKmGG6qETgm$ z_GBgZUSDf-aA2t7ObK@od=ah6i8b_$S`XMNRB}yUtUvxv7!dmF+$oNY8N!p?D9T)0 zos>(DT|bhBMg;%i)<#O?Qsc*)Bl!kkBFGEPjQapE{(uC@s5PkRAdA)+ZFmg``taAE zWV1Ewq0=_B6S2#PeNj827b)NS{Ko5#*&2gT`_d{f$G#gEvo-*@y>S~QcWaE?&R#%k zu@CMyki6PGlBE3S&i=pl>DmcJA603GSB_Tx;Ilkp2=MTIJ3~QfG*niGX5j#g$*-84 z%|?~nUroHc@C?|c&Q*>P=vSp~qvLR(Sm;}*Ub7>b0UInNmCEO04~Hrma3>$lJ%TQ? zia{g99^=D}fWw#Dq6t&7Wu0)AHatm<-TV#0!U9ksC-q#EQu@)q$KWfYJP=dGKi+0F zLPz~cCfp)zRiCUPilkbp0{24sKsMa`svGwYo<f=g>|7N?r z;h5}G_e>vok}1l3=oZbYj4|)5>ils{VwU8(v7Pdr+kxlN3-sRh@Pve07BDqE-cI}V zf}nF=sFvj+U00-kUFQ#hm|#3r&#n{;Wd0o)quZ4snzvLFQDPK0S$d3vjWh8dw&;-3 zjJR&9@k@w_S7GucjsfdCKIp}JuydASF@eW2UIR$Fj4wDoj!s!`J;q?1>MJ%y{pf?$ z3n>WLF(clY6S*mfd{ZP0Zby;&3D4Z6$Bqs>Gxm*71JMb4IzLj4jijNP5Sb{5hCo3t zFmh+UbXRaFd<81RoNqmR*}AUZLG8--&&u8@s}D(i8?? zC}2wY#Bat$9u)XH6>S9{LF^UphO;1@)F6Kk(flu`^i}C}sQxZ@>mC*2VtYkf6}HWe z{-jubSjSTG`QxYM-#pR$pX!5=n6K$>KV0^1b;?M*0=pdAH=7jkiTLr%SDWL`!)Gda z)CdDI-bVM74IJ(tCN* z3xUzWBA@dzwFFb&2yV`0W??C@4jMEH_l7QajTTnL6(=u*$M>1Z6QQaj(ih*lf?0^U zLd1gMc*75`MckpBp(6;<8OQU#q z9|yB=rwbU&9!=}ajA}|%B09ytQKOg515Fxo4j9TEo}Mhr)z=6(Sab6_i)P0f1z$h? zn{za)!>|Z{{;kVM2ac54a9M2(?rhdmUHEMUUt716`;#8a$>a1c@Y!XT&po-zZ|sF3 zM^rzOE}DVy1d=iwsu$!Bv#Fg&$d$M5G#>YA%-x@8J%8<=axaaq^BeI*=i(O|7kUSd ziVd;WRZnCWs{1J)+4gNLArl4v-AxVRK=Qd>Rud0fpG7rkAnFt@HlxOw4f_OQ5Vl}> zf}G444)My>|46-=hQTvra=bRuMu{fqfMhzyd57cujZXUKa(?0D{$xAIJ38CU@O%Fu zUra}6E7AwwimKTj6~9J~D}vKTBKW|%?RMW^A?pxczUXePZP~`t^P%Q5L2o%t^ko$L z{kKtXNL8NyQmg-+)C!O=Dhz4(2v)35U#%vUCa)5238PP83mdxpYS0-n!0A4zkmOcY z^&ns-T2>D^7nnD66S)7Flly85OPG>%dwVaKz= z+kPQ|cujqfj#_rvCglq#TIO>@KI#(}0qBa4B8YpXkx-GZ)FZ9pg$a0TE@v`FvKD;w{1O z&)TsXF&4+O4}?ZlZJc!l6UOzV#O~&nt&>bGEV0w~WgP(*kiHV%$Zf{Ig zKj6^Ta9(|9oa5rSZ#)yH8?~Tk9>@5l;`%pP`^B&0$mKajy2W(eN)H0Jis{=d1(6oz2|PAZ*h_@*6;sD1u!Bkq?@pj~ z`v$9Bov6yYJ&ZR7Os=;DUR8aE?7miHmFcxE^EcA7D-A%L!_)I_s~H+WjDqc&UdP+G zY9GGZvpCsvQ4NMC_Wk0kE^M7D;@fjliSlBB^l-%oct!#h6`Kr(Z-wR>7(EfOe!QMBE&cu|<$M#tE$}N{L0;5b zr4n(f;e3e$jU2~r8=LvSU)Fvp5UHM)V*Ri&wv!;oW7Jf$q6v-8q2)h>4+`>>nNOO1 zRzL4@Mamu5N78?fqXJT?y_s(W#fH@aZ#NL+xaT*Og#}jpDJtsDgbbpllYWZz6@7Bo_4W3&wadv*)SIH1?J*~Ky z3zkdy?TY|r$4GR2D_plSa~?`Ov(QlM>YA~Tvzx3M4z7D&;5?EuT*nktq5FcRE910n zEkSPTo@lyn&+^&JlV%me?UTMuRjE;25g9-t>+_^WzqKOOG1lp`G6}WoANWT>BP}c1 z0c?YBVA=77vCo7fIn<{1TZT?N)nle@3Za>UB#orGD$QHm5NwGtF0`Ngxyo)>X8vA; zOUWr6<36b%pOwC^osVJwrzsBilJ1TbBHbNJ0IxpKlXG>1&YZWRwU!^}yCjEiygy9I zFs;Fkcg?6w|4AydJ^8Px^HN0I$owUNx@V-W_1dat#xo%GU^hvY6i%Qw7NiAX zZ=(2$Mb<{xVbSVGQLUQWh~b@#Yz}qVoEbj_=j044z6y`qSb`U(cUsHs zM;v(foov>@ixcWaIqR|3&8=H&@2$BIyDM{-M`Bc?m=(O*7dxU)&=8kAs7ZG9uSpBf zx?eF;xsCKCMbl-JPqOfvl>CT8ZsJ9htBTxNxcKpXPfb$napm2k{RvF2E-H|-V_Yx9 zGoP+eUGY2xsllmhgHbY{E*qjFI2<W8A#z@J$$9L4sAw{IUzoRPDP z65NzpCOiQhKp*ZPJ=$GsEH`4w=95~J( zIfqwzH5he#QgGq^@_BJN4`u7DDtF!-|NV{oFHEQE55j9CA?_H7i|c8RSWekjkw(6! z7l!fBIE_?YkbDI!P)V8-V0d=Nz{jFDt(i z+t7m-eKd30rl{?Z&w!gLiM9`PX()K#aWBoB+Jpx)X+tDaYcKMi&FxhXGK4821ZFQ{Mt+|2YEMP7WXIx-hlV5J7Dc z>m#(sQ|^!VqZ?GN{Mj?AScc(uwZqU!b8mV3x@yw`gK8vrzZRuKchN4(EGK#N)inVQ zQ{X)PCos-oFqHo3r`lX0EWe)mjK|SdGI`D87_@0PXT=ftY1Gg@;`xgs$tcxwUvOH~ z{CMvQh)ajYA^B5+uqqM>2`Gd@DYCs&f`K-&tLABK4R?PXV&nF71iEiwQlbXGgW0oq zRghSeZ)TbRywmZ>0#|PwT1~b1(gL&uE4ha`F$ofO^J) zmOX<5M{$-Fb^~R88P^1|oyQkVdk&mr1m+ei6>``AbxmTW{{FR~hr?%Zi~Pb!2M?jG zo*HkQRoMDgHcpnWZt&r_r@*^+(N?_SO-pOx9(S=3e$Jz>vkfrR7Krj>;v48&fYIxU z`{;SU9`Zs{N5-8xeyiA}#Qfr|CkZpIM>df|8g9QJ`Sa+VY2v0e)xn#L#l@j#wU238 zOUX~Lc=P@m_B4n;y7pYqF$G}sof-mO?aMAlRPpGY!o(2vgbLGuJ4uUnauOC7vkTM3 zw{@y;%g4qo6a#D~qI$KZF1|hb@vHW9V1G5t_bym-Du}?`^!`V4Rr*MzIZ)9`$^>57 z`pC5|O(m5&zZ;#&8fu6#KMblwzVrMANvWuHG@K|6cXJF7#u%9mPYfPbaAluW;+R6U zeq7BQ z-`_zw(CGV16vfb|2hzx&4A=pqK+49;V$9oiq){gZQVO^5ThB<+MT6NV;Pu9BqdGRp zI@bVIpNKPj^wN+R?6g&_X=A#Q^y1X=POcIEUi6?6<78P`>3%M+1X!uXWszPtvjiBpM0gnMy%*FVdruEu2PUT>D8TBN=#z)Oa z&i=Y!-KF&?v}Kc85G}U!OG8c1-^8LdBzAnF3Q6l~s_5W${SUWUccvRarNw1Ir~_mx zN6V>`CVfh|uC$(bQEey97VbTADAdujKsnN7T%Vu=c4oX{x#OR$sb@nxNmv##cUlW@1FG=Wlw?2?4oFS)av=+kn|EvRB}U19k6< zJy(`pEy3q&jqmnN0ymiUJXuBae#q?T8VA02C0 zk-%45B=95Ig^Y@w9n&MyJ88CgW-bb#(S->T=5)~`@{@ar${eai4xJhW4rg!dv#8Po zg5GDFwNZv?{Vde$q1P<8fj?D&R{2o`@(3nje$<<;0Z#(LJK4Ec2Rc~%8rxEgI$bg}a3IzW za(AwB0;Tu?T+X{`SIpI_+|{iYuB4j6PX?UbyGox`;cw`6u-7(4w`C4HkLXIl3StZ# zHKqcNy*iBnjl17G-hNaQtXfmo8xU-r(lml;mNBG9|Ku?Z7cmP|(B%iQ75YyWsEI=Y zDFHjw`#c>07!>$LJ(3);S!T^mptyW%-npp1RjOo4^&x}{7S$|}d~4{Xnrb-?f=5!9 zBV3&^WX#U0&E=n~)pCohahB2TVQQ}2t#jNRH$#*6oD`O+T(UY0AnzGnTedoD*PW2` z!i)tbtbl~gRfS7UsXHlhX> z6sEep8UHZYZV{QFLNETFo>n+#x-(PG@Vqg_Yr}H_ffxrWv`Gpf!HuZ_?e+T1b!`96 zIDchf7Yy;iYgeW~9B6{kb~8ii#Mwrc^2wfD|9;UZc#gu$*5lBWw|U(5DI4cXg>f2X28VP4DEa7GcUZwD+p_vY50HLJsilBY@w zgqgq8poA>c`sqZt;E{lQQ{UdbKSax4PAO%D3HI?1)qiV3f>qj!;_bKjuMP?$)dcg| zEq!cfJGL3jFLgEgtYO1_PA1}KKk?}S77cS`g+!O;_6G1DW|P)YpSt7g%Fo|y3pP)o zwK9}L1V%LBiA)TeMsk^Iom0?F(zE`CqyV#V1$i4)`=6Q?j@CRj%Kf|lpsnixrZI<$8Qb;t+y>p?~m`oC{=ON?1gH$Z9|5A5AVLBx^%1JaC?mo zkM6TYqcH<%w)_cOY={H@M0j{DJd=+JeTc!|c?#y%oP5SJCEjd7vKYlzH5`+VIU-mi zxZ6oDWeU@3?#8~mZbzIN#kKi4*IdC{N=%q{Aao#)xH zhD~VKaR~P-;~Xu@=ljEIum~V1^xwKr5h%e1vg&wgeO&1CDW5_Gee%^6#=u0ooVboW zT5FltFs&MnNZ`>Cp`k!(?+%*TiS8p44yvEQP`^ei$|mW(J#sp#r;TswfIst5Ew5b| z0d6-*KJsnV16PVw{WPU9arnwoLQA!;*#hK=7=Ga!zmsO*MDxN#)WaDs-jzbbHIvbw z7=xmgId0SH8YqT2rR5$**dEWEzSAj;mB#-3ndLsXh4=%odq5JpJPMPK8puT#!N0wJ z1a#6}PDgGSCv&X#JMA}y`iB-oE2<16%t*;BQx*BwVl-;Zsk-dcy-$@D73qWme%Zj# zw3Lo$ZD&g%W8c!2yM;Z~-A>C-%7?m&piGLL1pd0e1F3-LS0RG7Mo526X9PmBX+eRf zt{-Y*D~I5I={kE2eM*gn6Gm)~24^mE?M#~$qkmSWEgB}~FRT&&?(0=D<;gg=yWU@w zUuv>0TNj5oa$FsU+MlysETPn>fZrV?XV~U20&0H~-h2UH@FlYqPoEV;T>5C>!W3c7UqyZ- z2S{j{sglj^+wV^ol}m9iwjvhB!>743>BgkX#2Pnb4Mq~z>ZTwWr>ezvefRTtcS;(( zhcP^Y&AUJUsNOlg+iUBv|6+5G^304;mp@%TJVl6#q$t6ISisF+o_21oG4AIx|Iq-t ziOoh0y%trBOt10ewT#q5;7w*||4 zC-~mu8dwKkV1ykQ3IM37N*U_lO=UFxX!!9~5!z*LUB&v7*rvtD7iZa zsivO}5Y3iN&y(zPNOP{SQ=Y{Fr%qeK3v=^((}^z3VMQi>g_lY8{a7h)kxmmN z)UTd{>iCuywadh$*CU8H3Y{`EMH>LD%aigySTZ^Zgk1cMr`$N}4-g&kM!HX}>}@}& z{B~9q&@D>Y2OH(}4f|NA6uDWjo9rksuAZHjEGBjRpT@_L4$}fdz6vI)ac@N3vp!bE8jR5wfbDCQm#wOgk23QaO*KcAmD_w7Q32c0eUfO&cEk;VT6YI` zcZ&ygTGRct87&5k-Z`Vq7HGGy!_K$HL#vt9CllWqqC&c)>5ndt@9AhWb7%GAu-UAySNnJ z6OWTgN7^GkQ*X*)TkwVR)h=Vg`VeG2O2oPt+cBL^TIP8}ia5qUvg>J~TQBWT0(`X7 zl_P$n8=6?o(>y#4{VeE-kEz+UF}H6d9%yLAsE@_Z4DL^mwaIpkaFTw92csPPzp)IJ zU%FMQ<}$Cc(e)y9!ovp5Ml0}=MT<%NN{LYDq?o(U^&pw9Tv|Yk^%IPmSEkB$m?eSX zax4oc{Ll(~T%*{VE!g{@Z+YtR56w}#HMYhVa9h9)aHG5)G=S6aI_xLU%QxT19W^}) zy9W~J&gaY`ptD5FA4E$Z|K4YCT#XgbIL&-$av$ljp_xHyxdr^&do+020+qvyckSdN z-dG&uql#H|JL;xV*LMml#VWJ0M;w@n1s!yYVP(@ZGz`b8q)G);P)*eXZ|CtLxDDj? zjDJr+5$4(aK$>gBL-LH6oMeSj5BKini#S|KO*K~N@0Z@dxCkkPbxQtxK+W4=bCP^C zX4KRg-hj;9XISxVW{DeX;0unU#kClAYCG>g))(!$qV_WZG*ID?%swSzW&p;Jge=F- zV$DhzTCVd+W4n>m!sxApR!K$C&~h)K1cvqt$=d$gXucVSA2ROka%H?zX90A)HvY3xl zt)GIssq5bLSlL9MO}Iv;s0@SIt4tl+e~BDi4|;JmIc^t|q*!x7jW${^Pk*0rwsESW z`vO|-n%!$SK3Lb>!1#nM40WKnw3c@2o2PRRxX*VL`19Amd-k{pb_Yz9+8>mT`)yXd zd|XGbcQw(94sbVpPLV$C^tbM(ns;dpc>(tZ04S;Xw~;w#*vW9I8Mqc@)l=plJj`;q%=_Og)gbv7Kf6q}6V&=)hP_1fxTZfkU^thKG zA6LK?OAmUQPHc43Vs?5*iZEDhNvJqIWVNCBsM>B!@6NR60x6L|UxlJ}9L2?ku4b6*mfvYAGsH|*sCRVj$!d#@06`aGzIT(Z zSV_7#zUEZwD%LcylbiVpyk)^?^e+v;XV?@e1O^$+8sk%45=hQ+ zQy+oZw-}9Xr^7F&QASe=`_iM>hSEm5D1kJoq0i6(o(~-SXra|L29X>?n_?qCA~;pb zhY4tNY5tg-*)(^(cwG+akUwfDScxj}*MfBthZ6Lcc+g zAI7HO2td$|%Cn1Gju?E zhe7FDPKF`i#1fdv-E< z{4UX$Oc7o?i{_6@)_qDSvy5}v8IxQdt980)NNVs0X@uar#R8F~GEI48~&U_BY zBdvh8dGR>k)~ELI6ms7*V}orGl4PqtbOsoyp<=n98oNk1-E}UlQ)nRJwClCx>#fjBvNYXX`zaR)%6OqUjCgw7@(nq(F-K)rSzXB zLvq$wVIbw(G9S)4YT^x52rw|4N0?o+EWwO?$NmSl_PND}uGxT2;$9QuaQrR`Bo;w{ zMKNw+eQb#~J~S|AtDu6I5!b+)u=wT6&-OVXMKP8>mwZS@L*v66v+ShUNj6G4L620H zU2~rvWp3Q7N3SqZE{feeGyBo{oC%slcS%S&2o4-J2vaL_ur?Zskce~oYLM)OMDFK_h`3lQ9Y2F5e{q&xj&r#_X{zCXI@bO|ufsC8pL9qmiHML8W6#PV!X-`uLWuUl;WO$M1A9nT0r4&pfwRFc({2KBOQm zj{mtho-9?xXyyJpb2a@MOMSbzAJ3k;+*G?m)^V7}IgKf@0$!`6I{L-7Zc zqTuY)xM#E1;soVNS$%5_%KGIeI+&y5L%E|F^5Q&6Wz#=IEgo^Z`sy6T_!Q*)1q%hV z|0c~>BjR)Tm7oVy4yI*IMu>wCvE5(U&^05*)rJSZ8*JGXvpyc-mFiQx71}io8;AwG5iSNwnit}s?tn3u zpTzz`ZuhfsXc*Ph=m@e{02!p#nA_jxH3iU-XU=3Fn~6Ppd@39@~Qk>*i0cpV%XX`w_MEs~>h_W+oxYHi{L#uwV;2OS;I2?n z1j?bU$M!@1*EH$=3@^DEXojh)Cq!t|;abpnB(GEujeSjiw4uJ!#4 z3E^K8b6t=npZWB2T${_M1@Z}4hw&gkM^^~MJ6V~1{$^1bh;Cl{iB!y%=Bi7gX)Z%#kQV@23CL) z_$^M-bnV%t274+E!NA?BW2dgC4*iXVyz~ZMFYo(-^o=78YI?!snhb`E{m*>)2XaD+eS`PPdJ4k}nzORCbHpEgNt zZ_&{*84`+kp_a5&u$WRR*{F@=rU6$zLLaG3kXsnGx%5!B6_5=liLxSVyg^+oHha3z zYt6Y8C+X++QJqW7oBr2uquJtCA*EZaV&#&pujy#ZX<+S>yq!<239`;xqDr;hWx^H~ zF>gXa+V}&I%1gstkhtPNdVDUyGAm8Jv>4S)udS}MB=kjYmw#g8J1Y(=HX=Il9KOGu zrEn;q#}#kQ$Q*EFt|xVuB&Bt)ucY*rX_%>z^s*>37R9MW{5nY&FXlW|fD!Rd-}6U| z1}l+AQL_~{b+Tp-%I`r%rPvl5#Q8;0(3x7zNYj4hPvE5dm;^n;x)RqXrYYKI>>lm(}3@!XaSOcy*0pMpi(NeRXos z#1VZFoSV57hb&JI(7*dai^I|hM#bcMsBEIxJn6kXX+_)TczWhi-Ux+u`IsGB^3;)S zT(z~iBwDIBo;jMX;@u$cTVtHeQOf8DnCzT;U_>?(=cTN_vXpXeVY8 zX(%+r-15IThXQqY_brZ>dOGNrt(owbKK`6aCZv8K)?bp|KG%AY&L6qmLl1)U^5w}j ziG?DQU=S3wLiec4Z)h^D%~1s_Nd09kSL>7avS|rCjnM5CqTUqLhW|x;?FkL#wg2f3 z-2cj!xh92!Sd?GB5=y9?8u3d>OYBw*z}0|V?!lY4J|k_VS`^$Ii%ALY1CngJ1@(7` z<_PZXEVf!$?0sW=!PGUPl^#>jz+femSJQNelHA`-YP@xoH2tr4C-CKZYIV>$TjeG+ z7oTmgz|xSl5u+kP@y;117}t_qBc^O)Uvpam&lo9|eN7URpbv(Ex`Yk_NTZ3UV~A^h z8Ijz}hO+e_yy@a!{R}1#N7mDYV)gzMNil#K?SXjc1y6rqUX%$IzqTIVbXjIqQir6j z&aeP)t**d*`$^qSJgRSb`uSlUgF;6zzm7^yKcC5QW`%Gc#;P`^)TXeO8cAK<;d5TM zLp;JD!8MQ;p5i26_7!-!jF0sCh!DGI_llp!58)%cT?!4SVQAd8zhYQoQtygXwU@7% zIx@T3T%|hW_wlAY;glhoe ztT%YkWf@1o7r3&%?dx>BZsM~-8f|?KZScR$Htn#J z?Z4UT|D(IRZ)u9uMd^&r!_cX#o@9qypor%>hE3o&7!wvGIm0ASu-bV4&RYu-?|=$H z#r>F^0&g%@e8C3=-86aC$IX8GU(-mcJJ!27RtR~hn@g=?VPh^2VRe-7Ug-dm;7JgvuIE+(_l56ECMOJ7{uGV_-jvsNs2m`;nfi!xu#J?@17R*3%v|x^sI#!=5Xu z)hE7}kl-@^thaJ%*ISUUElce;=c58ddJjB&N_Z#4QOK3&Dg*TTIak3Ci_ z_zzb&!jM}@QCoP!3bVh1M)f+{e=Ku<#~DMLX>!BDJK1neYi7B&qm3lop5q*u<2$Ou{12KZIqr;G=$(G8^~Qog3dCiZ&hPk`c4Jd21>xA# zCCll9#bb`Y3U2`j3Lh8JYd*HJ!`Dj{5=6PzI%?=p#aolgaxqWI7pdF0cMXdk&_)}e zxu)tmD;G*XD1vA3oV+oHu$tlWz|F?U+ix#Ph3ez8*KF}ToPH74&l!_74Kc--{YO3r zw~Ax@W^bp`(>PB)L#fH&mZjM|c+9ons1}`7`KKBq3onL9^ZWmm-uu$Vq9=Yyx8mLF^;Md=btkg=CDkG;y++i z|F&CabHH*%xC>qW4@Ij)Z^uH;&3AT_P;Vc(o@F?yAZZKZQr#v?!y}MK*`VNaaJ{+I z9?Br*P%I3d{6`L{OITmO^dh~o;om)k)Hro;PQ_D=o$+jevC_VS`7JYrN$@=&|DPz@ z-Mxev(CF9qI$QI7vbI*{%`0iFUYXbbovGwP{wRfBI=h-LVkB{5g!nvY%XvAAcvaPH z3DFPgjr;H6-_KQMvKd+7o4wDyp`|z(AoT`G@zfeVE(WqfB*8}o+0J(=@i_7f9K-`n zU>hMIfYbtKzY6rj-^WKId##1l$a?PyzfF2+0!n89U&V^8ae*O2gUO+IBiR z=cp=eDWzWXG0&wXaoSR1EEQTq6pgW_8Y>9r9CKAm#gO6^NevMc5u`*Zsx{OUl%U26 z5i=F!-M;tx`ThQWo%MHL*Is))dkxRMp8LL!-%;wWqM(P;q^k@xB^6l;&(!gypk zK1#3lo(jdB32?~&m>yTs%lSFt86E=7qc!EoZ(WHOGDgaJa#}(^ooa1<#MOH~4 z<(8Y}*Lf4mwd4u@?>b$uEBlfO{=@DJ2>F!o<+79Vz2;y7Ibs zlPrzn&j=)+4X~D*Xt%#e&-s}EX%JK?lu)Sb+AnGvV$+p5AIUaZj9K`5fq+#80h%1K zJ4Q=GZDL06n7H?{mAlVJ%DipGfU=hbU>r4#W31+`KpfG^1&-0~lApGHPg5Ku z`-kfa=HJUG*t7TrkABRH)A!*gMtRSsME-a`W(jG&yQJqq)-)zU9Gp*~J)N;k>1FtR z!7^!BsV44!{}jw2prmc}g4=;M6_5W^(v(nH8S4jIeX!k@uzGKLd`30 zO}#x|rzHI`{qSFjQX^x#(LW>0i!h!c+v$>+)Md76ozlO{WKH+;9dIvZl!*=8JaEvP zOF$$_ON*#(nU9y`nEkd@?oUn68Tm9EI$7&a(WzYs+tFx#uAx~{)FH) zwLJ2|OXNn*SJ#0)AE)x@X~CVDJvSxVvEMfuGKIA#`V zbA@NBKk2fJRKPvM*9iH94fH{=?BuF)jhns4ml-!`o$Qy?i75E;+9aab0T~$i-^o&H zrKZj4(HD#I-|^=k>#AY)XQY1d(~-|h6Pr3(l2u3z3A~qMtncT0I6AWBJVU_VxcS9) z(P0re8jjU+f0GX6zpM9kuvX5CHY%HB(@_OZ;&&QK*G;h*pWF)!f0L<8 zTc<+sSr*mZ?X|>B&<+U)O)o8hDugw!JNP#Q*)1++ilDJl8P>j0A$;V^PSs%(>h@3f z>p?nNK;Bp^1r>#n3=ZiDpy}C;~sB} zVsJKR4_6a3-7mr2P=s{7kEkuoe!&s#rAP?cfJDNMZ&KMiY@?bjdEwdoOSew^kATY} zDcL}2K|Q;398x=?LSCt>>+o(UZ>*NYPax1ogW4gxxU-koj-F5SIe~OA<@I3sxx6%m ztKCY$eXNmfE>vNiMHqS)QVSXtdmsZa0;F;|9OumD13yE;-(Jg)Nph@)b}9>18{to! zwD%y5SkUU;d#wBw^vUcof_TRi^F-Ck4*HPw>~{{k(|1EN6xoWK!ymP%q_Xxu$~X(R zKw5z7(XO(Ny^XNTLiWjtsTF_zV1>Cft(&0sM9UJ(WHN{Dx(etCe7J$gRE(Qf_05Qy zk=Z~+`BKL&^@r+QaZ`pItQRvV{$p#Vr$)Kzw@+qwOO-y`KdSs{MAsNFL0E>8IB$lm zhjQ`z$QS1;k^Tn@1&uimvym=N+P$KACQzt%c0HIyUA#yg_%vz}>vEx2=wZ(= zb1bb|mTnYmrscK%&Tf3A_pu+Wg5&vYomCb;Sbe6&0GKWrORLw_-!?(EttktT0mdKW zLBrl|DOWJoTGa_<`&?P``(aC!ktGp{Ii<$Fu|-O{{^Qdnbp^D~a@n;lXQg}#`1llr zq1fC|aro@s&9}W#Ia1(lzMq9;60m%S$`)tJ|Sdbc1lEq6kP`Z3Scn8v) zIY8F0B~(8_NlP1Aht>Qt%+q(e79A!mm(J-%CKN<@AVuyK8Km%Iz*Q=?w$&BW1eW1+B&$gBG$xVOBRn70_uir5pEi-?~+)#?*>9`wsHLr;lXBfpTpM~^bbGHRx9FlMOI9C-^dZcaRu6= zs-_GzsS)csh@261HuXCIaYSOGX387+7t^pl*wf-PCWMuN5 z51&=B^2wImwNDpXzJ)J~=iv+@U5g#)s{p79h>3H2xu6@87>E2;O>owrVaoz(Of&9j z5x@bm3LBMA!V!14%@^oB0=|<@msEbp09#DK25%%Woy!@w@X3 zT{h&p<9hNXP2$-q+2a9u*pTy&A`a)iSGC6}h%#I!L-eSioy6pdNHdaq64CG=4$-hN zOxte=sN|}WtuHz?s_@XhU@EtW%09F2Hq}USqz?=ebCdHzC@ZJfv8D`bAvBTSReV0D zlHek9ZR|n+=|?JSuTQqX<@kr4xP%}9HK;z6gS3^YOQ|srj}Nyh;F}_!d)wqWwEd%< zl~$i^XUA}jpSSON_np^3)YE{X>Z6Nh){T2rhojnYHgagG%R=T&m#sm2t~tmqSFMDy z;sEEKqk$P+f8o|9W;6(dJT?Hjn2yi&T}~fQ>w9*uaVvmte4|!fd%-Yv@B)M1T??CL zZ-Ck(fA&WyI+BM3eN6!N2f#yXkrA=gee2@q;>uan#&J1iyE>M-$KPyO`Xy~5p)whN zVWGjqY1Q&$8 z$Y9b!`Ru)(IXZdNXOgr}r@k)n=F7L=J`Gw?^BV_US28`n8U90b5W#EW9BLeyuZO;& z-o%Kx{~e}jlqj8MeDtUOOKs}7B(wLAmtue(oa_t2xR(`nwZbG1r$^=zoHc`Q@U~x! z8)*>~@Mipl`6xCiF+z1f?Ye0p(sCu_0i1FDI4FZdIBvPO{h<}l>!%yNG3iIF3{Ds)T=FTnF7$2)xb)5z6#gE4JYSZv9LZ*0%l?; zeeZW#3Oc~5!bT^%JIc(p9m}o2VAd1~unxF6W4m8;A#l9Ry0`o_Xy{)6W8(tZ1LTns zoW{NH0WoeqF!fHvgs400y?Tgg@=Q9V<0+xIttJ4ifG+==mOz}`DL&#HoJ|61{4p3H zNs3!t{q}fjce_;Va~8eA;YYb8Y~X>yt&?0Y1-sfLddF9NRvs`=Kl=pqH!w;to3QlL zF*P7f4F6(#7ORAn>N35f5CH?K413PUaK0B{3 zC*%MPl)&`rx#=gb3{6ySZUqV5&1v8{Vl}%cj6|?faF;D>KPT^mu(b{R4_9*_KGU^v z6S?O<)}C3Siwy>t*YHMqum44z?lg1lrd?JcUYjv1PyvZ_g0HkZl!MwKx+%_td`^+2 zqhtAv5XKC`21-!g#``)2y|uig<8&uk?}3T@tOawymGq*&DM*pCh|wF;LyG=PIX|sv z03dPOft~CfJ&YKX;82v$VE_A#3qOvJX{e%u15SP749n$m>_Q^@&(df!$JPpq4BR#h|n70Ji!URj* z`#ko|Vh6Qy!%`Rok*TA`E9VV`CF57rFY0#OmH~OF0cT~6bvg~+>SX2)v%Qg1+;&|= zAsyE7Rn^=Mtm*}^N|y6x!R=4fVZU~2|!_vnYCaX ztcQQb-muj@uef2-W%5uTrOg`!rfyyd_IRDh@5VqnXTGMq(c(dTDEqxU$q0N&PP6NU za@}GcP25Btdn2r7vm>uaE-hq2=%{ikOaI8!VW@gWK zxNn$U_XV{iPlv8=CHWzt3Cvp&n|Kb@(gsXp+xFb(c$dO(1ie#M5_n)vP#*9`*PXqX zgh20$`rD-K5fB@53+M^1U$wYX9X;~VRz1}tfUnyrfM7Ze`EA+}flaq38D!ogs}_)9 z(nP8Ozi5WZ-_NXpN@Sm3N;i@FYR@)S=>}DQka}w6BC#|nKKmydqSScRfB8p6;lq&U zsy>7Q0~py{FyDT4O&D4pV({uSz%2Wx{S$$IBJlqfffmllHIc7CcmFz9jbbbUe&GLi a3zdB!U-4U8x8RxM=TJingNoaa{`?Qh35Ux7 literal 0 HcmV?d00001 diff --git a/icon_foreground_android.png b/icon_foreground_android.png new file mode 100644 index 0000000000000000000000000000000000000000..95a2da6149a41dc3a7ff164271e1769cbd5ec015 GIT binary patch literal 22506 zcmeFZ`B#(I_CNlhRcb-Ft+ygFRV!eWLFP$Ft@SF9fD#3nMIaa;fQ({*V6AXl5ER0c z0I3Rz$Q)(@1f_z20VRPzLKtKcLO`Yj5|Vr#dOx2(;Jw!O{lj;y%UbZPMRNAp=bSyh z_CC+=SDh{&{^s~M0011ezw(O<0DN_9@AvD2;D1V{gHZtBR%`#uh3oeU`I1A0fxX_E zcW8HWF(&1&Jk_#(I_sjqSNoyHz5C#AwTC@gJw2O#`%Q7QxR#zw3vU}3m8Q6NYG(4+ zQY_A0(PMjlcgz3adqwl^(I5Virv25GeG2}af0k0uY)oJVeqW8Nd!%>h3DY{Kz`}$` zwx*U-UWNa1qbNP{C5Zb2Rkrz@-&$%2i%H_siw6jz42b{$9B}i zHgr2I7}$>fy8XZxoAoCEknoduR(@oDFae1dd)W(%ZA`othe#z#tNlsbL5wKSIL9lP zz??k)Zh8`f=+=@14juuLljVRbt#0SbM;yId@Aes^l`@O}L8>4?+~E*7G1@8w^lbe((r(lZfdCk`=nYIS4f05*a4lMt3YhAnqkd2U>y5 z@?7A%;kVY9E*-w4x@2dScw2iv;B{`_-gntSwt8^1aqbB;+N5E_H|mT%RG`W>K#=r` zlMsEC&fo3Le)1MbJ76`W*p=+;rHQd&Fay?xE~{f~fX6%A$izPf?}s`-ASTY~ z+c#k-MnKN`A-9Znec+bG*LxK1Xx=3%&Yf#e&7@UAP>y*nshh-&2+k>BYTw_)uMq;m zhe^3B+nh6IyhdAx<9%AL%ijV9`}Sx9eBbSH_Q(xaOj^M4jP`aClS#)_okAhqm^pq5h(SL+(XmQ?rvzoX$cP$yx4cEN&g!C@Jp&m( zT54{_Fb~ZSyRxxuAdVaN=ppVJy32C3qT7K8zn8Cp&PN~YguO9qnY0oCF_Nafsa-#^4iDpo=FVx05!uFvQB`Ir&AZzCI9u`x^h?Tp51O*mzjo4yzEswvYxuog ziHqy1-F@fzq1Q>R5;e*dN?3|siVlVsu-p@%Zi2>=G)quyri-!dV$v8!f{t1H(=cKy|0^f+N zbz)<`q*AsKMC8ytCw=Lsg^`uIm25kL7pE#>u`1xWxoTfSKw>`Jrj!|=r+pDp)~H7ugaThx_VChxnJUm=Dp9xY?U_Wqph zv7&UeFO~aE6O_s|{)p3Q$W`7=KoD>q#%8^K7NxrnE}tSwu4Z>fk#<`q-NeqApN?`U z-*iP^FY>SG68w5apU-7oz~hG0y;ZfhK1ap*BIaJIUqr2R1ZNmzl)+Uk-0c?b_Eu)@ z?L-x*;FhcUFO>$F0=t& zWoDN4Hub1%_ueMw#<|4oGDY{Kv6(7IsN5~LJ!(z4laV3ZXt;5VYHL7W{=D@mMQ(8R z4O(JPD^lc%{nEOb!x%n4+wDP%!^oh|jk&D3FfB00J%zvI+Qy}gCmW%2yNt26_KC8>yQO1~hGZZ71T7T@g;f}UaA z%dcIe8jZQ$ zU90ciIHyw8^SD|yI}GFS69HF7{}QhZlb!R_9(eWkVc1I^bhj5(_wIVni|3EgsG)|9 z>Ri&84~BDJ(+r_OLA6$d=WBTZod(~5$7=9c*x@ENk(pXE)%=Yl3 zc6A2P_z!uqV?W(=yBq!qGS9B$@*e1ABrWXP(EYFVBi;Dmter`wKf>l{;E|`Q6xtJ2Bty zI3-j_q1moae^r6!$3VatcGpb5>TE#>vuf2S>Y^7fPGx$Um_ueR9BqR38mE{bJ-6#J z%-{|w*<+{VfQ8(>jSWg{?lXOiBAL&9<$5lZOpeJnzV9YlZt*K1@go~x?s4lUfc%T# z(eBiFX~MRpuV-mCJ`Ozk#&8C4dY$<;?4^ti(hqO8)UmZ zEa_I$u(U(Fv~nzos_WoJiR;mCX!~N{eJnW&j8&7PW`{Hk%Xqv0s!A7IEvN96{*5Uq zORG>pn9o?JU80w|ou_t)8!hgUzqO>u)(O1m)hv9>h(FvSx@=BU$rMu1hg)gMeBbOo zyf_?g(hU?IaXknAkwg(als6`#=Nv|2ve%03Ho|l8r1}DfY<7&*%|`d6g^jlUuLE}e zAcPpOD#$vRay0A%lKSnZ;#VBWky3k;29)pi>@xRmxzWL?LNL%Q+@D9>8D+P9o?dj! z!=FGz317%;-7=TV>Pyx6&%#I$`m~Nk+e++_y@20s+@s&87iX7PITp<^LaP0fDRlGR z9?ju5q1W=NN+4zpxXq?91GxyzJt1g*yWfyCoCh`Vr^Ru7F;{v;F2vf3$gU8d;C+C~ zRUt;prEanW#;H6^yYYU_8Y6vJcW+krm_IOJDYw5<)lD{SGStM=Q>vCdl$F+!s#3hv zlnrH~^-%=9x zz4G=JvS?B6b-vW`536L=o@nesE^p#s4YhUf-c|HaKyv~l&D@uONe;4i;yrFY${slnUJY&? zwkDA5*%bZf5nS&jmV2IgnxVL!C;J2x9@|?~qpjYGPecDre2FGw;u0K}t4<>95^Y=F=u_NP><42v` zGZ?5i`Q*%O-CvSTf=_dQZ?X~~g7Kk!_dcQbFR)VQ-O=8>0=&*_Wm zt#)#bbV9ZtKPaJ%0#+_X?)gcRe{v5R7t`E&P=T z^xCHVg&m8A65ghKs_u}hKKyc- z?WYF))l!w738k9-4)y{A;uVBjN`SWoB=Biy$`RC&WS;Y$5kPl{4!6`5xddnT>z*rN zSqBbl$s7(%ZB}CEsv=oT;uTMMesujG(*w~~yTVnMnFCYldqVApc3^u%1%6(ixWDVE zQk8Qeo)dn5J5}Mic-04clD`S>O`=|rv|{5=^5r7VgQ6caD$-bm%=k*5Rpj!<#h?G( z#4E2yi##IB>fO`fDNt!5tNrk;xWlyA24;OlY)qr z!$yupE)K+p5*>uqd*1S~rPXPuROp2Vvr5_(&Rl zC8B_xbmv`7(a}(?T?>A&XSZ#sH*%|KvT#j2c}>ZwQrKxgnAi;oY~9I0*-2hEB(7z< zXmxmRUC(sAfNnak*S&s%cgz9p!pkRFT`}=?62?tr4bu`*Z^=!m?};yhpq1p-t4uqa zHh4dvlK3G|)Yn5!7_~nF=LT@~D8j?goQGBL#2HW9&HY;0i#n0t?94Z;Xmlei_15=@U zAfJjGEAW*+-W*l_n;b9?I$}V@bz>tAd5x2*&}fj0twSW&er>=K z3z8|cnVEhPqrp%@keL4A)uD)VR1pNzGjWvVhG3@$I&gBCYuhs~ttTX#H=2gE<)#Y1 z1@q|@CguIaXbC#|{_f7tOGp?k6ftR|j`jEQbqf)d1$d$J+VwI(OuWD^Cf`vv!h2t* z{T{lq!pv)HRW~;ybdYQwz`sLqvgj0t}_f2xsDGQTvZvlTz7pT2Ku ze2+&1v$!yq)GWbQf-6iHllEP5X1hj4&jn5pwytvI?mn|)E|D|OLgkV->^nhBBl^o+ zwV^+4fT=F4TuFfw9Xv78?@GgwR~Ro_hyzkmK;T!U8y6&3})$CZJAFCKQ$0kuGjpmJkB z1{AJLG|;3A(C|0+y5X`FXznmeb~SjBvglymb><@XeG<1eG}F{ ze<2w2Bs%Sidu8U&4{CWPNNGte32ss~B=jG#z*vad@}*nYGm=(%4yvf#%RX;znHAbS zTjM$J(PAQ@y0S`%Ku!7Ho`w*7+sf0i0(oO6ZHZ-x+We$wL=}NQJGQ%njqw(}TWJ=x zg&VrbRtR;dYSLnL&o?QvE(pqY4t|)ptGTy6{2sgzo*}ZB?KSoBU@b{}lhkeX%$B?1*^BNvGf(QZ z;b`bmgNtz~Y(`*nS!;$UonTrhJO`SOweFQU3UP}Xs|Gu4t-d8AVRf3PRyI8-;myaA zyQ0%F-=>=+DPOC1nznu5$Xf+uf}knyyyRMg_z&m)W(%%{dcs!(d_8odSP}Xqtw1_w zvj_j4-h+ROiIOdfwAB|X3KOUI47=F9@e$K;Tf#Q(FKheMXtIU=Z3z^+DOoAV6#W|$ z+DfFFls8FV@eU*_?8)0B^CXrh?BGuR;mMt+nWr?Is?Gv6QOsK)j{iS2A^$tr!hZ`3 z|E;xj?g``nKgv%dbnGEu)LSC^qS|m?WT&-#S2Z|gC9)eT5JkSym>CZyCLz2#z8qQ{ zajWw<<<0H?5+(008OAQ+_fL8K?U^=x#p%`)!gClQ6^Ft;Wr`jU34QsE?j_@QJ@w;! z9EF6qxv3D?ZvJAfF4K{=`q~}3E{E#AF+$IGSs`44kHo;DX#I=6vNw3&ZNo+0NtQHq zHIgnDu>wZImTrBMiT8^%nWcupf$NQ9Z}Jm9T(7dH=id;q=e~5LMGND9$_>94Thy_k zQn#ZE^&9H39m0;Smd&Nv8MT~H4ap1JeY{COS?*#Ci<9?EM7Z>HZ4zQg1VN_jN3WKd zOIu$SCJEDnUmpi7p`a0&@*0)(6Wvy~M4lnSDNN8cFhv6qE04yfZ{!o11_iApOubYH zqh`R=bdk9BGP?Ty0M>g6(0V%|6SXvquTS9|z57PVPQ6U8fmT6pefvSjV6gJsOL7nR zT&@1-AO4KMm-3KIALPy!(X(G7^oj!t6+n|Q(1uCmFAPsZgndO_Pe`Fn7eb+)F2PnF zZC_q%aj@v{Bu4DI(38j~C^B@?%u&7)uWVbpS8pw#NVNlBzVli80jY&mgE^W4o0L?$WNHlK83bSsFl4G3b9x(6Ye58TPB+*16jFtS^a5$CgQtQo`*tELCsmFdk;hwN~1h#D%xp>3y<;;W* zfiBor=;8nsn>kWpMJb^w7emuB%?%UY$1I)&3J-!INzhO!O_C0i%-^ZTa1A`};M3{h z{6yI(S~kho+(E-TMACb=J=H0d9O7BXU_4BulKAS$^sk-?JyA=+R+^8lB0L!|&huxC zWfr$Ma)>$qa^i8Z$OBVn9nE$j<;?}uDHt*FVFa0Q^sKyAoRi91FWJ~$W3D6d$qJIAJwTP3H4S$Osx~>PZ5|XPwv`1GymSw zE!?z%R?;$Bu5$~l$4|lB?Pj~vz4JOyk-KWAl7i+k$8js}nMaHZ5p!FHFB7Bm^G1X< zw?D_!xZ}JW;Y9pfMtJ07P{Q_%0m8fqYK>kr4t&RKBoC!p3sBN`rL4A+42e#cpVgWA(L6`piPYLL%}jO)5g&Ry?KLL(vG z=2oSLMz0&2U33u)CA>Ytn0&6&<=%9Uo3`W^7bGg{pw0hGK%L)#Tjqrj=pgXP3TTOS zmy2lpdnFUp&yl6|R(HDV6Z5=3w9P(65re3Ixetei7%*+3P>I%_VRoZW%ws;149(=Z zixnT_k~8!6rZ3$c#%4F77)sizasHVik^$x3^p8XBn}|n>K``Oc0;`{{Bkv97+tZuk zD9gD%!S?V_Ns`P)8;n{ZdzJ=Qs}~t#8?Yo^&ZHX2Jfi(w3`?;ix`C_aXH^IOkg$?a zl^8RI-w^WMNimsQ>ct7CoViFszXO1vbdZ6%xm0YeDaxh0%PYiLrm;|I-B5sslaSD@ z#>+uFrOw|QFE_s}53lA4s-kCbe%FSWE7yY!5}Mns147vqvX#ujST|rwbI*0m!(^W? zGaAvk7m422i#I+*`>vsWPSF@n0xJ&T)qz&a|2mwhd@Xp%dXdC!Ym}S%aW6TUSDf4! z7*bBGiXqD~HdX^n_<^eh%8s%dL`cA>k)%Jos=vS2j}zEF`uO{ETA{~71d)&~p=>v7 zStgf^`3a053!?d2yJD|mC)$#1wPckw2J4R|Vr9#TEpIZZw^u#Gs#=7`3~LlGzsi6T z5{#-7@t_fGE|8r0_xYnl6LGi@1o7@GdksdqHm}{>j0`1KBl1iyl8}a5?wRk``_Xxp zXbgzjTThgisHQTj?8h|a-JNM!w^8_dMDKIc#U33(o+MRKCV;xPgB+a#HRC-#Eb-DJ z-uxng+0LK6%MSCKw4$46;a1K<6Xh|nM)igmSG0jLDlX!!W0O7J-0Hn`D*q%yBm7$f z;6GN9;fmcQl2t`89(<`;fv1v0^=rko2fgf#I4;VAQVqgF>%ttm1)FYq3C$1Zo*;*j zRtCo7yXZFufC1AzHTRGVQ~Ne4#NH34?$O?{cT+}U%PU9*+y&?l7{eRc(>SRii{rKOA2!d$ws|e?8f7` z`E|qWQWObBH{P1Rj08pHsIhvTzi|qf))Vp_m zC-VxZgfXniwYJvfkdXqHxSCH3t)}j{8-2G6s9{NO4Sb4$FL(E3w@R@($+1Pmr9F9; z4&k7`v)ML?T~v;;Y+NjsJkcxO_=`(8K)fcd?kF{f_XdxvYe<^Y&K~Q_U)HK`5md02 zeHMWKENaj~a%T*~w=rojYsh6nP-L`yN>=NB#!jpLcC)QVot}%&ZvN(A(z4R#QYdw| zmBV9NfPxp(PvwO#zxii}6DpW~y|KYE3su)k^}Xf!6|j&Es%g~XKwf)`V%%;I4w+r= zU!|XwWV?cdr{qoG1oJ(sK0O2f7Gx|8v%@H9czgxt5}t0IR_>p|nKuSvx~WFyf+&y?x7X|dKwc(bpQoo1TJK4ZL{*bO@SV48)(@lB{4xD7 znlYDd7=Qg}KdFi~Lv9mGF0|SdyXC1yHhV*A9%dRfpeayI9bT4=?``nlRMNf&**Z;d z+l9qX#z}u3naNipg;Ad;F2K0?){VVsnmGb1SL5AW$Lhojd;-1;O@BneOoMB5Fwu(~ zMtuu-{q%Q+jTOBpjUV=`tCEM5I%76J2E0U!bSa%uLJZYw2i_TK->yZ4$F=@7$Fd`5 z@Sl{8M051NwprJ8={~_V4RGlFc;P6VU_%t1nFi~bd;KK}i%U&1nAL8EFN`r>a4E&h zlj+rBUK*OFzmh+HAoGeWkl6YoVkw(NTephwTAu&o(KA$J<)kxZAkrjoW0|!)ao{UJ z_ZQIizTLt%;kKan9haE%69k>r_Va2}BHW!5$gfZrOreK=URgXn1Yt-amIuOW{jl?D z+&j1B{>ItZ7g0b&A1G5#0%CdwbGG^?3bKTOR!UaOr15+C(OiS^Gn134FHoV`0(WAe zG}Deho}!o*p5&^rQb(jn4r@uGgxI(XjOYTkM6bsn#u_GN)2#Rga-9RC?T<-7gu@bV z(j(>J4YZU)o=b^YFFuPfut-etYifj>#!$UKJsb1I-Txw#N-|*W)tU2igB9Gz-YI@X zIPb6H?3mXrP(4O&Vl`u1#|xK%?OW8ig@eh6da26lX*b#qIWu>5GGv+J&hI?>yK)^@ z|3er%9gLzEJ9c-O0XI8#TejBQbH{c^Za>zzzI3vmaz&^@lhsrA0X98g@9Orst;69E zdG2+B3e*@KN5mNizPe~W6JbhSWitjA$S^noc?VIv^@WLgHDf`GcUzd%%9iADJyO+3 zq2_eKXgaRG%xZ4AWCUl8&eIQA7US-6s29Za5yWIJC0YO>9%C1aHMw^WxU zOx-ba_ZTw^MYyPn`;-$0*XJC%M_3uND8(+4i^g&k)AA-=EQAxqN3OYW@Feq9S$k2( zjmbcdmB2o0?zzAXU7EO_`W0Z)zsJ5eta`i5B^B;X+y1?!4eVl+;2j%~qmNrwBqe&CqhSja5js5)GOd|vp<2@PPI?Eo$HPRavZ2tHTu=@@~lBa&Oyhr!! zZ*bf7azIC+VCDqwneFBVx(z-i>iTwVyQJ4f1-lz|w7R2xB7{aK)vI#2;wU)8>ZUJz z(I7B}$9tpze7Od?(5V+Y85Vh67Shmeiakdk(Z}VSBDGf1BSafJ6t2^U+$}m{^Bjd-H z6RQz$3;n3AT(@sjfGPhUz*gbR*Ylo96vfG{kg~0+`k5ziL=-A`$c%b5`)JDmBTE`3C9oOYNJi->-W_Ztvrq z1g47jnu~2CSQfOF3fhJWf@ID;hcqdY9{qjN9?CQ|y!SKPQ!AeN`fcp+B@N1G)osh) zXS8_X8fIfbwQfU+f{P(Cw#tYa2U6ZR7;F@QZay)rT8vs)DC3q7|$)W6=Tz(oBP7bJ~3p3HpwNw((!&Zl$RC zh?w^y`hOe>?N;o!%WHfjr)Kky!C#S{TF+4oWdgHnb}8*Q1>jt&8U%R1^*zx?DE;sw zWzluf5gYy232L<|dSs?SPl=;G5e5={KZpDVU|o=9>9PdzneR+DA*+VZo4Fj{KRo#Z zFSk~7iLH`_6LbVwOqeqaT<&>s%kMo-Qr0``L^Ww7 z2UR%O*WC=b)59cvZvb@0%#y$fKISYypJ}#zzSevHeY4kWH=O<|$9VZy{(MfC>6n@Y zgrBt1w)1`QLtx4}(*Hm*kAJ|q0y7vNI>Pl6h}zR%AhcsIpr{o+<%^VeqIp*Q!F|cR z_w?_9j0?iTo)JN5W^X-;VH(zC$7g_Hh(j#nv zqK&9@S2XkYH|w%TE#<>2A>M@A0%P;a2st3*DcB>P(JML}uFtjD4IqUYXAs86SY93q z$DACf-Y>`MQLd?D1OXUN@3~F&A1yopOC()Cv#ehQyaIs9PpW-q`L(LkFAU_Xa?=L7 z1N{JC85`CJMC`+RYPnJK?5tx>qws=yYLroWZxVXFc%g&6p#&5Ho?z?zSFmb~<6_5J zWkD`HSigXTPkLEK>R`c59$#)S2<8Nw;oMib)C>T+S3u}dcb2V|)HtgpfsrV^H!WI1 zo-U&sIohEG&6?~iMRZQeOP={ku?|4oxHo`I$QKEUZQ80&G;I$QMi_G_jf{gv|UKdj&>%z^+c*F z!?ovTl3uq*-5nCH3tw({?_m&#;RS-tWStOB0)x=+Jt-cj;N^!=mnO$@w7@1j5%mx7 zAY*@1GX|Fbt=6$y7PD26s>ASUjL{;%%1Q$&5XT?=kd1QibTBV1f%Vq=1U2(*Bw&ko zM$NC_xW9@fH;)8vbo)9nsxmV?&)+;Hxqto)(92!@qk?@2f4AtZw;Fiz%f~E`0GNon zB?WWEpDvT55i59Ccbwgg?uhGAegzP#!pJRw#r+hyWR015MS%MGEVORLcILz)2@gj# z1sTsjCy?;%F}+1i?IRls!150kcYx7rZa%vutu1rU>|0LI1sUGm=?t9Tt3{4NP5)hW zMh^cP4&7H%nP`RFC}d$`LDDgD#ZF*Vw3Rmpz2O?yb{3A&Pi&#;bV;Z4BQLB>ARtzs zg)U1v{aqKpv$Gj^d>n}Nl7W5E{ezg>4tZ^uX1t>$soM`zJhx+RrkzH&Zs14qLDfQn zv=@+vfuPqLM6c?$FCS54qg0g=oi4$>fp*G!ZwuO+jcooc)Fpe|neyj!zjB(vk*kvi?i4ENFnY~D>N)?5s0=9g`;8BoZaG>@TS>w^6)dtY!i zMbl*Ya6!^?dqIalW=8V;X>O!v*wI(;PLJH)hA9{qxBFMEGzEP+NQ2ljqRX~^z{sPk zIyxKwo%RZB(MR+nG;x&45^ksqH8TOijLn(1aB!6^LCHxoURAZY!9U^)pdTKtjthjD{9m~A2$ z0o&M#r=q5bS&`hR+(G|jjT+&)XGjznIk3v+TtJAyvvE@PPI-*~B%Pk`fqx;_UWuqm zTs72@P4zrpRiQ@_Z?0~==?M((uIMf)e2}dTc8)rZ4Gw$yw1|u5T0?w9CG5%d&uFhF z9@H0WA*T^|bd3jeUHfA?++j>zLeasvAMX9)9q!n1=>rgfS9}AI7k4tt(>TRX(CIZ{ zP38Y+92}mB<=&$dol5%cdqvLSe_kx2oR)kHPAP*dvdm%%Dq1gf8%mY@cMmXVpAR*} zOLfV;k5`HHkHI9u#SuS`GfeeBG?s@gaaZkvUX`Kq=yi`2R6a^Kh}Wqq=dImTdP{2u zFDNmFbuJ=?9&1T{*eX**^%%?uSk4z+QelCHTf^aWkxdfY>_CJ=EN$OEJFOZMR%Ysm z_?X82hrDacor0crN`r|%IQrKn5xeP zF-z1DJ)^@5>jUiw^~VERZi&R!o5<6=`k7N+&3}qipe;_mqTfu>R>zyVQCFHCyF1!9 zX*AfS^f#1o8x`SrCK$`N+P-QiAH%H74AC}G-FC?cikh=J*!8;6t-`!yGaqbk5Z?52 z=>C*~k(l>6$fqcG%6P?)%L08MKfJ=O?9tu zP%0+>IrC_%kU+Uv9T{^TMzg)WW!*L#*?t(69m2K73`%jlFMj|Dd(C9zS)ZAF8)2(n ziV?x@3S$0nFXxDEE}D3Q6W}?yT2qOl&0b?w(h4==!0v^y9@L-0PmebYh7 zoaJHxS0c&|PQN+O&^9Ar!xNRnSxD;AEj(pnMV_$8_8EFS_xbc>t#{Dcau2`rl5)fv z7?e9*!1gFCO`i*lM?0V$mnf5A_QXK3-PAO)D9nsh32$X1Py6FJws(B5Om6xIZrDPJ z?69=?t+?>gv7mQ6KgiJ?b^uNR{}(n-(Mav1e5bUa5gs=32R$bxUrGsaZ&I zWtdxI!tCuvt+cQ+D01ZWuIUK8al#Iw%8@A79JIIkKhxs^mmG)kUignJnTAbH741B? zC+ZM7)W1)xC?!>TixzAJ)?4^Gq{KO?t$r0{4Cj0(vtc*cN4x9k*`Jg`;g5=3C2Vc&MyESwju2!3U^EVN1 z_87Re)S8VVr=uJR>$CFrKXm+A)xNzIC<>zG<6=nG>XkY-HEs;qglYGC3{|?iPZU(p zHXpxvq!9L5DcdhcSk#$Y#9yl1{oUDC5DRQy-FGngNMiy$jTbwF{f?B>hdj97>`b3+X=6EMbx8_uF#)%;xYB({IjNaBr$GN<&*q7BjHB)cU2&jGC{e z0ziPkX*2?r9hPW(M2f@@Icw8poAOR2wy~^xVqV{u?l@#{NG<_IwUd1I_f(tyERNDB z`ZY4{aq5e_!fw40ZzbPTUO)6s?mmRH*1xB$pMwRAC3ZAd&!u>ESq0_c19z7u*P;U++;RU z5-t2Ar2vo9Ys}eoOC9qyq{Kh?H3NwFxR)vZI+no8%v&2?z2cS!A;43Tq~E2~^6JXu z$Li55_b1Bt(ib^!SEf#iMlABC&7j#G;l_#^O=T?l6Gw(|Il)&KbfyXpVC1O6{PL;^ zH*7L_l?}JIQ7_>hy|XN;=z$CBB&+!`h`K%;YSa~d+C9#!G$ae1n2DDAh{}mQT{bB~ zw1^5>d)eeZ5FG715V!p+3rGLdF~-b-@s^#@EAO8KGDu|z1C~w~rQdQ`&#hNF{iN1C z(DQ8M%YOBQVPSxzwdoKz{#Tm<<9VRdiF2OknYr&*u=?|A#?5x?j#(E4(u?eC2qc^( zbOpdxJPcgS;dq*CUJ)u=fvw(ax+Mo#f|p(ayIT?o?drOO_T>C}M@*Yrwy44PiP+bB zft8nNAOmYBZQ^tCv@e984**k5U_k+(aXHw!bdJn!1{bOLbBC=*&Z=c@##lc!m$Mrc@uVM6adOE=K$)~WJhz=zh(264rq@~#46nQEpdGO; zNDU!#4**X82D$sES9`tH(^z~hDFY!gRds-G9I&ugO+I2aJKJ;x_pC(nLiXt|&VlC7 ze;a>Q74p!^rWrB*41b>rV~%WFU&YC;ZfqViwE8#N56}f~KSz91RpEq8`zzIRB-^Te z->r}{{$ZnquxlLUk*btgmQvx5a{n>pe-;jX_H1m>Dqg$7IhA~*Ruo0dfjcF=L*WDa z_8X}20H^#t{@R4jSYcVcCkcY)6{uMz@q?fx=85pAeR5qs=!_*g&J^sBfAh#-i&f?E z8cZnn_?#|UwaJm6e5N`?`$gm6)HWQ04lZ&k`D>=ZI$(Un>oi!_%wNGZK9lo`1|5Jx zfOuen>4dUd&DQq^GHwXl$R(9#Wj(Uyu^fZ`q^DaE`W9hXbhmcVDCeuGCeX@w{cv%z zR3p_?C|J>qNN6S7Hjg-mGSC~3j@+!i(G5vejarR_$mbaZwtHz}UjV!2;4BErj!3Il z=VCVsX8X+e4j=hGb}m93=xp_1Bw2yyljvX(I9}yr`N02-g(RQ8(Q6z2tl_Lid)FCU zt!mwNMXJYJz{_ne^SoI@hzZOeZyXr2?;+WP`tA5JfJEY(%s29P1d?N0Wjp!vlg#&KHC?it;U z>WTCD98GgjYu66bqQQ=UFI4at3n}vhT>-CjDZfs4p|##|$k* z2=kJ#`Z0Z$+Gt}>tqDj?l3CXg%yfif-&p-T)bpx?oVz&e8iJ= za`F97iQo--fuHadKYKOX?qYCRK`eaggGq4NB#Ym{JzU>2E4&3t2vn#t>MeFN0&2L+A#$wC?W z*|O>WNj05DvtuH@*Ppq|kJ0#N0lOu5jEwFHF5q(gZVI%pR%wxJz-5+r>&{pPx@Ta` zToYxdky6{qmp7BOq@^z^EvdYjX(fwkAeYA z?7PjG@_O)YKqEOaG>~y>wVXF@rqnX2Ta*C*dP;Fy?CY{Iidh&S1%y-0Zd22ze#?1g ze-xwI^RPm?E97X()%*x9)+RtgOAIf_%^NDQ$_b=N?!)+G+;XXAeaouG;LCojw5K8f zSP>i^tPn~{0;_V3`nygrkXJa-x)YC4c#1`ayKFo*L=~Wdjr%qJB&{9iF_;;-HfhSE z>3u0Zc5F>2Rbm}iI#|DB*s^NDHjq={>h4N{m~Z!!D^jwm1gKV>86Tpb_0IG&Vpe%z z(Qb>3vveR?12q4mMm!ncH!pUy#9UX)Xfx*hhb>ev;cf*4d~1R`vRRhDi#W00>pFNb z({obzdt!IHT1K$GDVqMly!RZ*>H%Ist4e^zYq??MZn5_E>%+ExTxl*H75C#JzRiZ$ z7JhRfi8xv!?)H4781cG3yt&kzu2UN3{Kx2cVh0~Wz{6c-G_BDGVCnkx-cEh~!lPB2 zGCtK)pT;)Wb6C-Hk=+k<#KMp|p)##`-p}Hj<5Pt^S+@#nE+o9l%zLRBM#vfp9ei|N zVkn!Z#}&n@0NWaSoLrYYh%1g&i5y)G!kUm4Qz}F!SjGX{C4p?3@RIv@^eTfG2=sg! zVkP?VwMNK%wq9zC2$g-K&dJ2AY;IFa%MIJx`z=7-gFd=)Wivr6fr)0}vvhlOM)1jH zTvNMYIkf!U@j?~;k1O6(+Ro-Oe(~kNMMCUxW9eJ1_w)KRzj3+I3uLVe`@c}x2^>%3 zfW)}wtIpEVzD(pAMCi2PJyX!_Fx1Zp(K)s!T%EhA0;Uw%+s<+D-(Gu%=BK3)xLS6^z%xL zQnK>7EBg!OBxvVXbiRdlLx5O%o1`-%7;WaKC#9yBk_f>Lkb;T3`O_Iq0lTl`npvGn zsBla$zc`wv=-h0S7fhLW@cgk>*qso8E1DGc4yVHylp@Ws1GPASI`Grsh<|yB`iZ`& zgU#V7e%U(S0uYO&dLDWBmO1;UYLnF!K{tfZI6s6mTy2@r?`@KlV3Z28R%r3RXoO?D z3~WEUn5#1K0=POciBEi zXqIs!noY;Cmq*UogI^5PzNO{UXaLxRquku99~dd`Ame8e#aM* zHNOc(Ten~AnXReh81~gSzzayGa5%ThylV17CERy~|#Oj2VrDesmD*wb4c z*^AVZ3`GGJ*;en2~J5Y5KSn zR;sVs4WVB?sCzE)F3PjV40;C46XX<=3-4&k9o1 zH}fb$+AhsK2c2$Q9GiD5754(03*wJphhj7y$!=i;+D(Y``=13ca-tEvByX~JoK?a~ zh;8}4!V?G4AgC#Z(n>}y>u2QJtd{mpTb5L#gX09LTORo6nfzKc1P_N?cBVSkm@JuJ z)${LebhPp~aor{M%gtWQD>R4c2SYoWb3_W98%{Ewa$o`jYb}0wyz{{A4+kw@RP8oE zuM#egKPl(xBsS&o)g&Cee2`C(F*D$r=!In3f2=Sz^Hxx3NTDK%G8z{5?g?L2fm`qS z)(5J|$+;m_DwHOgpLv6j4vZZ|=bN3||xn{={M0U0I-fyw}_#DX%Yr3R3CZo>| zR3|)KKqQ;=0ZI|wInHg@RVKe&ayQI%4^3gLD0Nm@sqCI_qFrD+M^z2#SM(4 zzl}xrohz*_o_vtaVg?|{dL#A-wkawyNY%xmx3o3yp-S7()K?Ks$3Dmn6bLwBRousM z$eWW;e@woFGl*^#Hqi~&MkMSD=!6a^6_wu_0F=v@Z7)=3vBtdLsRNTFZUN- z#7wR)w{{q{+~v*=yw$<*lTvDR=9aaB6VzKVnb!Oa)t<-GFm$2lV{>e(wF;>$&&)eb4*# zdY*O4#B?ir}7Wnfk?TOW%c)*)N zy=si~yXA5_FPN6TAha?kjS;6JMvpE8>4LAPq2WQQ@6t^}49QmnMJQr@Bo>s79Yd5E zJjE#mVu*;!>PoyjAiG$j>B`0MhJ{#3LAK)P;AUMgYm6wkmP6-pGRxVfc4uMy%hmhd zpMdki#9(|;?#bo8Ijn_Fe#yo8F>_Kv>M6X|@S>cOvK9w+8D+7neGtVcFL6rY8;D4! z83#AXx2cf|Q6ykH9vKEI&Fa?H`0goMz_$Q&FAStMWAj&qnj}|2>b3?mX$hRRBK&0l zl2iOsO9w{F{Jr$twuCA)uaq8-ZshU$(2atm243KuyjuuY`~G30|Go`--;SLCGy&!b zB>!6b*YMJ-Fxru1eSJXrzBsgFPxan8W|2c_W<26-f--X0!mBLFH6a#>8{?zsDMW#z zK7Z0)=fd6e1X5=|0_;SM`>m-zm%l&JJYw=oYrPsz(3~F25Ci_tV?*a0J69V~Cz|4B z22zh~^yEwX-TKQ--ayRewxKV1I4_u$P8)6^Ydfp-)GZ~a9Ks?F*;K8kxh~Gz zX!8uGmEyA*tO-fJK+Tytm!Hx_dTxJeQ}?!E)RtlCx*OLbNNVqO&d5AUCf1(8;uKSD zmW-l?o3bERmip3&#A;rast~T)5s`=e%4`H6N+mgQvfB_^gbAdzG|=|>&Q8hgiu*g3 zrhfEFfKLU1=#d4VBdKFgTUJ36RwaOBsJ>SGsJqC1ofpZME^eR)bV1RPF zyDzg>YU{EBKTd#eXOnxOLyQg{-Xb`^sKQgup*J4c&SSiE!Bu+TuIx%2MP&8 z-Egx7u!(t0c}NrUSZ;ju1y0F9Aeltk1>j+G#3Q%NL|T%W1TI-J&Rh)k(hXG}^w7Q7 zlSiQh`9o4+?9$s0Vq5tR+Yl0gV@FU*Q%aKnPR?Nx@m~vG(maaTos~P?9 zMSvo<2tfodv^_(~*gI+^>lvZ#hmqnwli);1;c=k}1-7F<j9h4YnI$h01DxKsf9( z03)$(>r;}qm+~H`yY0+KL)mu4rLkVr+-dnt3?28WGdp$hQG9?F>?SGhKYf3c)GTEs z{@wDHR(ozT3tMLp`}+dBr4k60GoAPd?; dCQ|-!L$m1k7va#KUqN4g z0Pyj#io#zHy;Ikxh*OOpeLLM=PW7|#62HVI!Ucbcew%gi$zSr{XzyPkqBeCL3chJP0=fkXVRPc3Q8BHEnF&4NeS(+p6l>z4tRCHid=8YO%Ko!LZWP&o7<2(TQNm3 zr=&Uvx3r zuJn~f4O3j-rD!(wI8&B-`SJVXPr_9?{W}N{2!T)#GBMfqYF8k(?=o%la!1r_E+e{A z5dov4xWSwN)Krd1i^9TCPBu!Q zH3E+8Cqi~Ic9xh$3)H}HX&H>}34|Im$sO>x)~NQr?^TWyIuF^&AGKz+#z&v9>w=0a znXYdEsC^45SyzO#RtZX|jN>kC0i}OTj%=&>N|MU&jlMe=int0NV1rntJy*Jmk<+hL z9GCT?%Y1&|%CoMAsj}evUWi%_9lCGERd`M6I^x@X$U*cJYBp*?G5oP&PhV4%+^NC# z|2k+a>2KOZr=uNm_$-MD1YJ1+An~*`__GipI3x6y@xqGkP{~Z&A=)OVHz%N(?t{$e zGctK02rHL2jz9jCHSL<>o_i@X=NpQ$g)?#K++dZKW7WFgmzTi?6auy*Qx*niNsix% zJlg)owElWmt~>g4y|LN;Z%1je%=?h~g{~IaMLVeZ<~>D_ReeWVmQjr6Ahlfoy^J)2 z6g5KjFG%m#ygllF)aau8AFm67bXO!c;g`XE-wY3VgNGj0>konNbt2HTU_a+^#*4wA z2_Rn?yf{QtG~d4DKwQtkTT|zZL%Ieb-JuEb3GGmG2q_~00jv}g&OIc|E&>66`QC~P zf$;`IL0M`)TDn$e@kd_Niw@{Mug6MuDhtFm(9mF>cw7NcJhp$G4$WZ>6>8;OA|PWD zFk#tXNN$=&i=)(0^rgD6uuwQ4EA3aE2-!gdm&Sn=xWI{!H0#Mj-?tCnFkX@;qXOof zk5FnYl0V%oIbc`Xq?+f|=R%ssQ^`R~aL{(syBa?KZku{n5F5zIr1GA}|E@S8!UA$b zNDCa(g1&^dwt#Yi^etoq>q&I^3#=#R-KY=npd$iw9&%&_K|S%SII&NYJY4P`XYe~+ zr6KfFSyu*GbVa0tF!qq7M+pPbst}%Cp7Q6mY(YJ7w%U)sORvi6Ykx2UuY! znJ=H-4$zV0Fg_qg^d<3}aHXEO2-H71LOhIutbfc2EMngWX zc@$CZzAg=n6ky<}w;mlc_w{UgR0WWg)u8R>N(-^hm^4R0IQB9mfFso-7n|+6n$bp1 z05q$GdafLN@i6dqWRRjjBtQzvxD;Ur1vmF*AU8BwF_AwezHz%=!#4x_1@hAaXzycD z;wt(fTbcT*4r&z;ER#OTL)H5;*9K?(OrpJdB+t=7jDfy5-3HQ? za>`onp{dag=sXl{y9Uus9MT*UX2G@BQQGSo;7p9uporc;xv15Z26A4ZCjwy`zL+)i zpk)U@Xw>~Ru9nPoqFT%4k0H2108K7CV+Klc$N^dO82WMZjXb~VJyv=J8QzIngq^e8+Y)*~-^&!4aoNogENknYAsn7+Sb_xcRY) z%DQyZEVTNJUhhR{qzC}^tyGRvj_2RZ-d^j1_*~AoV~SZuKgSu;u=5U^T0(KOZLz3@ zj~PZ+*U_Q1(NQ6g8RUbpz))MP{-DTt^<)`#;UW>>{BerTKv6z{D8J3-)XaVA8i(R% zq_vNLjF1Fqy-vpFmV7_S$1T>Zp@;aas73z3mRj+fG~yL^Xs_7QC~w8;A!*tnl?k?@ zT=yC)urioMXpaX#8M55r1NN5P)Bp?s6L;Oa$9Wg)F&q_Lr@|1RX`8=%0PN7R>8;$r45Dw9S^|)) zeQE9WX$JNYCL3+AE-kMKIXABQ(UHRFYc$UJww=-#SIox&Lh=|#XgV4S+iq{(2$m^! z$1P|Tb)%sIB+J}RRL>9PbfgwQuq~kbb1tL8ae4(;>q^S|e!vo7Bp^4`zkmZmjaos+ zJ7*cV*|y*4oVoS0R~EJdgU}K@$p*l}Cqo8B3atR}`0?H1&9CT|iuB@ka)Q=-uqIbO zg`+R!_t)tFV)GZe4z(Zh^q$0}=8~UVvLT}tXi1A-6u|uB4W1MA+WX^Bkl(yO0zzWr zPT4kE$K(K6L3i`m*>O&>N&&>rgO&)i*q9nnZ2o|P0eok?eWE4FNZb*?0K&6u`k_VN9eu*QJSxI-@3wr1AWSv!hSsMrvE~yG6 zcv-Z{0rOyu_*}Kdc=Cue!WC8$aAAt0Sx0UNM-*To9dU77Xo(nT-4HOjksBAFd-T!Dav7Ez0Y|*h zikrUe)b*Yj$lFAyF(U){rdXXArN{(DsQ|VyF2_doc}Els(0PxjO{i@pG_-}?#0rss zqp`29a~VU)Fklj=(he!9XYz ziEw}!H7*qYVdO~TI#KYofqcfp309byk*6$9)a=LnVT>1`czKjJ!{v0VUa$7PB%2pg z<|RmF--3@^PvHKoOzW9@JMy!{`V>2MnT+7;HAZ0QR zZ8LLvNFwUEWu*5Q7lfHkyU;c+z$$rNzUT4*>ancQF&0Nuw5KpYFxw1G*^*jhife%Z zwe}p*oEn_@TAw#^`jyR=dPrqnG={9u=edVcL5m#qF6z?ph0jbX3!DE&D3VHGqRp&`D(7S;yN(}&)o8|c-*?kj~tYI zTYPGf?|=*OpEZwt$!*R6Vovu`_bVp$0LTn7HWI)hp)#IG6a+xaC#JsB=ZSr>!*3Wb z+zWvtic;Q@lka30V3>j6Leaa*cY!?hvJep^38-Cjvva^i;UabAYIMljldZ(41jltj zI&v!D@m(A>)xwB7dA)vc6yuTLfh>OpO~Ik5wnn594T8KWZT;&j~mmnjAS+n{}2jLoMU8`tlDR>E%k0SE;A~)$+9K+*u+{ zJvu?dyT@nA*k^rHTHy5Bhnpj6*HwI=bOXpfX{yl%7kcWwO(P75f+M)PQ5mnVzJcN+ z`}AfZb0wLd^E&H_4HbCMt3bCMq>85r-*(-TQ}d0qvJCwzC<{7!F~y{FP0>QT0JJPs zpqHEfnPBz0hP_1s#ytzm#R^UUxYDT)wOf7PCW+}-7-TnxQIiX#}!Sp27{ijgCUIp_SJKSDdJT$lM^>uQ|jF zveL!}V~;UJr=f+Pg#Ja54a}dC$46P!XhNB%bqc+QK16Rq?DU}=Sffg}=kA8HKa*_- zrLQa(JIyF{3D?lZRqKxbU5nqQ10l&+Q_MZ|{u7SBwmtxJpT5M(1e#W8<-qt7DmzDC ztqi3DDFK}sc;`FNJUP-h%?0n9O5XD=5-~Gk^PEs8&KsL7_2!M!^N+Q?b+-Qrs3n zggM8GA(`F{N2U=@-G|Z1)?mdj`uLd@rbIDvb@R1Rm4W+Ih`F*1=+pq&o~iUOX9LxZ zGJ%9tL3;LdqspGxKnpFyhBReK%aYDDbOt z^fIo2gOOp6v>7@qla;UKY~a%Xf?Wbbi++Lbgce_z6TDjE#t@;K@w{fXq2`WO71^%w zk~|Y?<{N`g>K*XXWfxn<&eTvlxE1E4A~L^X!nV-^)o1Yj%UhI8v)jvTh}&H%#0z(e z>b)(fi-Y?3ITkw?yB8w2y{=Kch6@_>#dt-gLBT@yekW%Nb%^GMC@xYVV81!+ibv_8 z8wmMY$yDo}L~BSoS2iGfBd2;s4fTqE&K#*CK+*lS_^Q;U+z`0nl|beT%Mc@vAEI-z zO}EiTP!1;H1SebKXa~G|@BLgY5#wac%+Ll$ll15UOPtT|EoN9m3rfn*WC3iMoX{qe zR9ao%V(-{RmHV!<+AAM1O}|!v4i3a;!D$eY5H;3-TZZqKZd(0-4Zz5`USXM|0w+c35mQ}jacf*G0wXn z(c>?=)IZ2kp@ADD@@EHiSpR$)TtGMJdSi_3ss;!Lb|A(-742+r5h`E&8*P?zf*O3n z@vTb>9DS>YwyZa9j{=_AoWUG%mBy$vLLLR!ijO<0Fh>dRa|12`Mu7k=NlCA3&_QHi zf*sY1!k0u~8ygoDBYP_VT8#%U;R7wHSl;vW4vc=HT!K>oGt~7!6?Vl!Al^9|w@#Ut zHI7O=a?7DYW^jQhQ@`{*KeYKAeC{4MD)?$cKn9}B{RQZ(!i?W}?`x6D`l?cGQ7_oE zfNw+`c|sq4lV@Jh@GbSo!$yp%l+Y1nBJW}@Vvx9)0#qN_H?Hi7j^FgUMo5J){jG-i zh4(0lxKsq(*T6{1>l(zVZK7GRF|-|*W(IEUt4=7(B}T>ZM=)ALW!JaK2~A_MDFFEp z_$m7$v3?}-R#b@5O=RWIb!dUfBDX?(qQ$%-ZYjdTR&~*&D174uJbDi;;>@A$G|c`U zrU);zc3lutgpaobC zbFtfaOqNd}Ia)Nhd_sSR3PGolKpuv3;>}tKbYH1388eCe$P|lAil-a|d>N$dr?T7{ zCG>z3Oo{S4aY5HyIP;`Vc>^9C>8;bJ|I#Z`cY#3FV3RDjld&Wwx>yc&QEEh~1KjV>^TV zLGs%YI)Dh1(U8c0SVhBlkD*+k^>JT?woFdmf)fNw8cFiu_4>AR2LrR48WnX zffk5;Ig%^McevU<(P(jO)o6D#U^(Z@mQ;s6&3lCgS))qP`8!B1wdOZ|U?63h*Mvt# z+$$nb5%&IznQ~}5q$tnQU~O-l({Ls8OlM!J&>fq=!u9qqhn7lPVtFDgHo0uWa$?#N zOXGjWkDdB-+JWx`PZF6R8xpbEC#r^SrSiKDQM&vvtZIuQ195)xl7e0#I5l2MplK#Ki3je*y57&?eJteM zn(KLJHDWUwz5Y?!!FWhqMfAa5!g|(i9 znECD-h-vo_4-__kROpD3$ee^cmMH@APU39j*9vNmHFf?&6Y($1lVXK7hCDphbm|Lh z>Sc0ME%nFJaW<3lgadECVkIz>C33hc8rzXGdF5=Es zuueTO{@lgdc&xza69>QD+)a%!g&J8sVdo9{vC#Cwt&{p!;V^UVO{6KK)IH`bP`(MX zd(8C~Y6uc7Qr#?tT@?4H{_#sm;oUAwI}s;AXmbgO(hwx@G_EHN`K8M3GKcTW?nVg& z0N>uHXbzN1Yt(mrPQd4eE}FBZ1X!l|RBr3Y7uJ^B)7YQvK}EvHbj1+>qU z0q6Mh=9P$(Cmk!yR0x^?)7ra`>hE8Rvu{7Gx4P%>S>VZDkzEf@uvSPp&X*gHK*{$Ise8o`zwG%8V zf-xIvI+OP#G1E8+!C$S!lai8D(WA=+ZJmwXmTA}GtYy1ROB(7IKis~8H|D`Hxygf2B*H&eUjW3xAn^6E6XZ$*fR%2zptUe1Vk z#ZD{2Y@7Me2Wy7IoW;LiO?n4oNNorucdSEI5g&zO7J7%d1c6}9~M|M*Q5oA7Vc5!@Czdt0hxXvsebl_lc&10}cnlmvJUh+3> z=%cg4bf{PlC~3s0&QTS;e|zf{)LqmNS%@0q3NSd*F!2poC#!!00d3TV@M(S^eMV(O zD9&Ai6T}_Vc-Z3FJn;SvBdP{%Uuub^BWe20`_3Ale-QZ-x4jV4_h1H!CPp&2Li%`Cn{y*ShWwU@{>)k4%{Bu!{%WI8>r=>a71YJMTiq@y>VUC{!G zg8VWjzwuQw(?DEVE;8+d4FvrqYv+a6VCD6G8TZOOCwQACu)&09GgWh*R?L&AZ z{E9TE(>YtoRSnGBQeiA0k^jYX;S3T)_QFMND>m?kl;T4wfO&wABFHO5HRX@i$x1H= zzz*JHm(GnBEqg*7=CnfEG={dsmzqom{utjEN35t+p zt7_7wSL?^55YWE}NgkbcD}9b1FelQSduUTi^(Y0KIx6&p?kC(e5&H=*=E{8&-@1o! zU8QjzQzbMAAM`L@*3B#jRB>zHl@Iik4{t+0gBv#&&rKcq^GH&2{I*v_5nV3)S0h>pU;E@;B=345oavL{@6;UBWN}9x zfXyXtc_ZH##hASlk_*<^WN=rW0Qe>%H7*vkN#FL0CBGg8cTK|nJeM}_X16>_FX?iZ zq{$%(=PU%fG%IxT19f|8B7{VqRB)21uLKa_rCT63|zLu7NtDzS9$!0RF`D)*GDIYUZ;&Cuu> z7eVfJhlTNcN}@hX+=Py`y-pUZxKhI;Yf=n-XT0H7+1360GIyj=hT$L`%W9#byRHd# znc-NDunBSZ@1y8)?RC11)g8ENRkO&v6xQivn8-g_xcT!k=R~(#6tk zKgUMiI6FEt-FWe;cz@md;H=%mZ8Eil|!K&lFusn-kH-FFTtn=f+a_$-#BEn#`~y(&XD z;3G?MyX{Q|`IrZv+FjF+Y`iqRe8)vnmWRd~L@dV@bQrJm349)M)a(14@I}YjVVZ5R zRdckRk-lbQ_U_P;2P9PJYdP-(N6BJiTUjyw(H`r zC$s$}X}7&PR=ls1evHFZ%Z>fHPiJy+F;aM~tFMbyH0}?SQryorqHCso^6oDuheuF8 z&AiMmohnrM?EcIGzna|1PY-00ovGQ%Lk*ki&ztIxM0GW-zPeeaM(p?HsqTDEXBXR< zPA-3jX|yMw6zMEjY7O#DkzzizW>p9Zr7MQr}pC(5GbQe(l?R^7@4SXOYoDtgm%7wotBRM-$ zceh5zCB zexGt!lZPY8VMDy!N;Jmkakt|1mq!~vf@)fmdH3@#pOAsvCHwPAZ1f|8XjTD}31wok z*Srp?EBa2Rr)>^v`7vdN+jGy~0%~_wEUx zp9}0p2Wif#x_3XYbRf#nzy$VxT27cu?C#@JPV4_7Vz;5m-hbk~+T{~F)>>@Dce1)$ zA)aP`ZQi|>9>Frndcx@yLbGcDJJ3FeyntY_8cvq(rx+WSvb?s2YDI0Bbd~y8j@CFl z_@n-1D&1Nm39}i<8zGKS_yt>zEY+|+v8$-hh_TM!iJWO|<*i}9SH{pyAiCt}UHsX3 z&_AIkfBL5&3o!U_JI8lER;O{L3a%BG4&B}${`z@+M{#FJ|N2hSzKnNp8zy8YzvAZ=f()j@g!QsUti==g*^;Fwa zGY*2cYN3wUV)E~o{2~+WfpE5r z_%6Li8m5nJ*n(#D@(YF{nY;blFXP|Jcqp%vsjtMre+>%~(1AC4NFJNvgs%2SZn~p( z)=OnyVtw_BCB@(lp>bUe>GZlfvD6h}Jiy~yQYnGR2ThVDOewA+1D;1Qrfg}{Qtc$W z_h8X??RR`DK!n_>wA-CaS4JIy<5V!BYcpZm}bHcmr6;SL}O-fhOAVQq9#x_Z9Y36iOi$Q zHG1}Oy0iR)+EVg^K(k04zNKT_!aut~+xNQr>a>I@%oT0@efkCj3&UK+C_Tf0?&=;r zFBiv?9;>8QMe2hQ5%6DYf`k_#q^8d$(zzSc*CCV+1#jqB2Qq1= zIMODE#Mgac2hrFF=SB_)I8Yb0zFOy55B%>;&O>8R@=qlCwa9SJ^EC*~y=vwP1Y5E5 z#1KsRRc+EBvU4cBb}!?Um37tIpZ-)wG&@?~qnJK<6Zf~Xiv1cie<}944e~$CT zQOMS`auPocq6PKUZwq(k#Kuk3VP#Et!LsL8Av8wF3f~G)l+2pn8Z3mnrenYG*Ha_H z9>jWQAit(+)_>uUof(}GtVnv0T=$@1U7fu@xrxp?JR*)+sFIt*G6Gq}p3t8rZptrY zXK8Z9(kUyZ`zfQ@_9#9wM&>S>G6{v8EU$Vhhh4? zoV&T~^esptvI^&?P3zW7V(jUy7bl-hA@#=9hLkpf6^} z*Q?6U)%mnNVP^lOZ@z3{qK#M2$pMM3=&oDrkG04;`1F?qcEN+M*uAJm6@8MX7<69k zwk*hUCw6if(Lf`el9xQ`>lTXNPl;ngH?$gpq>l$rvhz&DuNJw%Te?LWh)2+0q5D_= zbW`ZdqG!at2|2Yw!%}q}r+w^;9qeD$m^ja7KkFbmlwG=s_f?&|0B)sB%h5- zIK09qu@IXEuzAT?CA%&1hI`Ok+wL=hR~`Qx3%Y1?hbFxpe=fYAsNQX8sWY!?wh_Uc zufHR(p(kfMtneguQvb5$bwQB`zBh0u{EK=Ub4|k=X$vTi_Jnmf65x(Paw%S=0 zneICHS|HS&5WMDb_uneMJBTT*S+w4_w>_&miY%y;@j1Ge5| ziO8x3V;-rvAw8MEj9?eZyv;9PdU^amG`T7l7FSy>h^EwuJIy^RSd{J6aY^QCYNtms zmDp97s-L;)i%kcY02Uv4qtS0I&l|v>5A$0f^VmLe?h=ELY|x^ZXfIz1y{LYFno6=y z>Gm)y|2Cywaz(V?MrXojOMM9hQ{16(z7VmKsi&9kls+2Oxm&{U_7#Ien#Zrb&TkXe z$Nj7R24MS7$*Yb{S6H>`cz)RyxcTO)4Ntzx-Zc7bzkOIF@sCUD5AkDr0pw%)H8-!v;2P{fr{!vp1M793@D(t&`Its!IFB1;Zj%zbuM&58bQM z$xkhpx-JygnU|BRXR=r=K0+=%y>R&2GrVffIHZ`7?nX^SX^mQB@3p$YafY(49l>yG z64S;PMPFxTwr#V?0i858=2U~RE{@3jZw7~-cHk&VJdz8@`8sv-3ns9-?2~quPxcpB zlbU#1iR`Y$uebEov~Mc+5Vs>rrx%739EOp`N1bWL_09)4Ka-SV~D z8G-qgfO=ixO)J=eC2{oSo7{2ImK7x-hxS8Of#aKuU5B>=d3nZSdw);fs1Mq>%n`-C zZD=}wBzHm0GJn!oGzlxQW;iyEjrraDX1g-~gIwIlr(UfeE4Ol&A|?*^;bgg%Edhw{ zKPe_l;lJoHd})$mh|$!;zI65-pF7Sc9{JPL+BzB5oj-%`ISskgSueKxsKoLJKLlj4 zwY`)Aa@n8zW#rst3x|#G425sqe%d&|8L+l@_=sYyp@xW!Xh7{Nwk+yKwIz|cuw7tY z)a9FBHq!OP4-M|bJ_>kJmT%nVuM>-TTbC^UM^;~`gTOaTO*p0Zz2T>t=2Nc}-tQh} z=^Gqk#tG0jke~O}%5sv3m@9;#7GH1UpBFGJd~L^VsqAYqYmUz5ntJhR8NA49gsz{d zH_c@o(Ur_j$7we&mXvg`mo8SEgbE81L|3ON(`a-n7IYePHPV{Do6`DoOG+0oC5~ni z((y*FC5AJ0KH{spiV27EOX^8zcars{@~XI@Nmc)n;{}4;qIM>q99H+rhd+eWycD|X zyOvt|hlrk2c^UwjW!*OBFTzB#9E=j&h%kRkmTd0fv070^BP8^4QM&D^h0MV28)++I z5_xr_oP%t5SnrZsu8?epNTM)QiA-j597trL*`^Jh3fwGDjArU*O$R&K-ycjSCXb*S zq4L!NBIaTOqS^O9DK6ctHJn>Yf=BqQ=5QOYDuVG0Yuh~M+@KH@PkIK18->DR8nV*yBiTA<|zkO zXruc~xQ;@;G#f#}2fapeesHQ z3bF+lbP=`P$gPI|62NRWljF*zITsOMm98=Dz`;TZ*SY+9xU3wl>Y%YJNJI@doF4VW zKtRB&#Gj_$WI@6W&f=ZE0+2j@pXC2||bx(TI$%p%n8gPNakRB zj(eVO1Pe*iJ+vgz4V<_p)l}gj0xE5gJNY*>1R{&&`AnH%a94;IZd~H_Tr~)pK6MIv z19fIRv$)s4Ala=w-c)80?wXHn&S%1vefctt40J)BG02rB#R-=uEsEWY8+O?l(&Y#D z9Tjm@nTCO?*@#>{09`RlqX@A2EI19&43vkTKx2l{PoJojgjqS2d^l^-AXV}gmnj4MKC)c1Ue)487QqS(t zQic|Z2EOX=7|gc0~pWry&dNRHWi2X z#c!+DuYENBxq`OhF(`7_@x(EDGnZpl{wJ%Fq)86tH131zRuL>G6^r`ikAQ~V@_if* ziI;nKv26h;pT@OtfNJY1h8uIp_cd!5?)tXeq+0;rm0mTo-M|#mB3xdp;~GSOeW_;T z=etHUJDzBUvouq8Np)GpPvjUoOxo0F6i5WY5adS|r2S5%AocdfkUg%~1Zf_g=a6xG z8r+!CD+oU}{@+;a)(Lbut&5|Y95ZD(r+HYW>6inI**1Qd{>vo*5Bc-(_eL8|`hbdi zO288yqS@xXFF2Bwk~bc2%MZIUMpa?n=G@^pm`4OM%4s!RofaX5QqPOUwW9JIX=6Xi zlKFw04bDg~(86TDY6xMJ<;CI*kPqd1x1>@~w19*8<)-6lIu_8+c!KoYp^Vl3FNovB zd{NV`2>dbpq%K2_IJJ)W>f%2n*uWb{9q6%Ue1M==YU=V?4*1#aqoZcL&I^1y8F_+K z-HB%Pb^GC&{i1!{^(xnFz8<1$_TCEc9UK^Ls^u;?vCrFrrY=^+B%y@X$i*TiDKZ3; zj>(~JG7oTsUC4a4F|#l&mDht>%%aFZ8fWQDEkx$ta)tw*xh{6-?GcLK!O{jcpmqo2 zZ?{h^lT!m1EdHnfjuP$sRkr!7bZRz9nBCu5{TAbzlHkxC^Si(Z=ZVpGk7LcQnCx|a zs;LQDW~F!zci9{4d*bwXxXBT+)?bZ3N$cM)x>599oSn#<-ZD4PRi8h{(8%rq(K|YH z#EZcLrQa*B4gD0KDLt?<&RLJkFf$nyf{}$JI1vvkzz(SQf5Jo>$QOQ-=mymzVc zjXaSpz-n9@I+6hNFoBWJr7(@uUKCoYqeg5oFs&1%5kyIi}d{|1nWsLhC0!RnP zaibT^9FsYL9Jkn2#Od?F+u@Av6^E2LC~MxUUyXgts5cQG-`4s9~F; zXT^>l(3cW*K=@9t+N!0s!fqCF0WJvi?owBW!v|W8{stj$c0VzD8SP5d(PwCgSL(FB zW85;Si09N^YTmfecP1q7pU$D$LoiQjY{$x#3m&lcU6-24%PAQ9MA>xb^lO%xV-^sk zr)WZ_IvTcn%kgP0Cw{uEnj6>?%wAU2`^>aPJ$91X{S6^Dq$D&XpCPVcfB$GZh}x|u zRy3SXztPQfh&?i20+gWQ^ssMvc);1aNhu1gWr2LA(zVqJ4;H%}w|bRW z=v}QtSxmDc2fV)vy)d=--9Ck--;E*4NuGAq`8^S^mM>sa81PCo@=Q2;s3$&xS-qQ- zdndZu&5ETk;z#n>K5>(4r=G|g8%V@K^E_Dbo2JXKF`eG@J39D`LgO@(aFp#c&N?`MdTs2&JAe)>w|FpoeXh@V| zBtm$%wd!#*jO@!EX_I9z6z%(8|GXFZfc}{B%j__Pd?vqsKK|39Pk~-nv2?aJ=Ns}% zSaP0>L+O}&c4uDwz!RemsvN=}YbS`U5Hw zZ~Qv070rh}C43i*;hPiiDY_JgQpu%AbBG@6&Mk6AO~yxb*7s9d8qa6+>Ygqx$nq># zrqfvtmfD*hf1}=P_Peh3bjZP?olLwR@b(L^pCnMzAjZ%=8m%xvtyTT$w6BK^*pcFx zstTP7p~@Kv8@IYveL9YjQK8Xzhu^uS*#^C-LD6TY71Ie;hV3`*wTXxg1%A{y{vF(j z^x7}7ukU`qm9Xn0hNk;&nO>bcLub2OFO(B5T=3BTv*cE2>q~1Nd)mvZlQjW)Gz0D`vb$RCB8@n(T$frq?j zFV#DJ!bpT=R5e*ms5U*+os}^`e*kV z?bw{!z3D&W#9tVPd0Ygi>gOc}?T1u5Ih?oA0qr#s6@SVndg;aW)vL`CeI*|6r^mt;S96lDEmQANs8 z!=d@p-Na9G!w3_RLK{$KdYXBc`E<(att=seeX{nCrel&yEY=p+9fX0g- z_o@&=1J>sk|t@pp-tQ;e^9MO%I#8i^MLNh{J0KA{;cL8Kf}Nj_I@huUu9$S z0-yL0&2~Wt92|j#dy8cShg|7b_^)MWc6>XbqP9i+6&fRpo}Qyb^X`^CC24}&DT>cS zG++hwIPqxg4v~4F_`lkAL*w6@xH&*ahvdhj2Tb+g*k!gj$FJ&Owlny2LD5Da-aU-E zNG9LkHr-T+r_y|p(!(d`Hbvm%0J4~r^gpf`xU0$jzgl%PTVBRV4No|4DEOv-J%{E!h-i{%!dJ{S#${+D!8AmB6{?y9*U z2py8X`HoF2A;ptS^{tKVV@F>AbHN4l0VUDw=|2_#c)yHO;jfmN@E~5Kc2QHDgaO1$ zX+y9Xl}ZS*1XcYX<+vHC?-S`y^`OohY#fOiZ1pn zhsQaNi)Vx2+r%;0uS@}TWlvXSXOw?d;tRh*4GbA9wi zdCfD#ml}K|GJdpWkRs+DT0q~)%q1!((bIRE$o~Azn_5l#ZDa?HWz9%Agz9$dEnR!# z?B8rQzkMHNTXvDw8ksS4a#Id=skBKd!GK^YI}J7Tl7K;q1hi@iuq`oi=xg{Fsy3u1 zW%Ui4#3uVrrF*Jbw_eE+1}sZzt?Xm zEEZTvJmGy6UQSy0F<59%(3fdP@Q%U7ORNLdg77i}TTD?`M3`T{52ayYPR^#@BTs9E zr^EYaw?5T!3;*ErD)Z%p|CNlT}8(!sRl>K6^Y z-M1fZ#Dv+dOG`3?z)>$o1%<+J8E?m;S~E(izkj|v0=*{Kr?ijSNUzxdzQ2gpj=q+Y zH0~T?*NW-%LUfAlT0C7Z9#VBpyOOIah?p{^PymW56XL7dev$)$@1~2LyNYBU1uPYy z-?ct>EZu3XzjcUm)#!fQAXIH_68UL`Iy0l|s8=qaz&nU!>tuU{@+e@NU%B$*JgbUN zLTfM9zj*YZ^T<;h`|Z2j)xwAFUkT(DcuX(3f6uSNrfYUHr-t>!T0Kd4Y_+BGYCqrA zXC-Y{1uL;(V~Z`D>73rEOEYc#^iX`uc+%r0a)5%dtoQI$zIWKpX}E3M@!}!)rG@t& z8(39HWpAl^i_LH;mGf2)G0>zjT$t8nLzh2()UdVnW$YiGvwN&f>%k)j`kQOhhd zM85U2h&3Nq+0A?;clP{t0pvt~M<;@#EvJ#c6n?~BVf*&mce_|6;%Js;f3tryeKg%#rc41=B+=B|U+O?xdOTpLgCpUvHclKl@YFLNoYK zCh&%-4O8%67u9k(%qY#=wY-m?==5T~rweeVD3wkYkm1QDj$jt5#<5KM#@@8>;ZzeuUZ^(v;^K>r7Fv)T1!$iP? z>gVpy`uB5t`+cI0!ow=tfV@JvNtQ-HB2dc-SlwjwBusPD@$Ss-xl}ADj@lA7PznJ+0N1t4;;8cA1|Gxi-huC#GNZ0sNt><+I12-xA zOWa&MIWHA54r*}(f6Hrcy}YNOp!qaf;G(kBhGg-p{^Z}kgWNp?kAnV8ZIt=)%pD@5 zi%GA3uUwS^(G@0yS8|nTFxbKv)`Dy2QNeTnkNv+f@RIPe^Z)NBM*`Jz-AXQV;$(k+ z6LTwb(CaGR_8$@aBmavIPhjwW;8Z91aGY^^Q4!Dobq=`(4CfojIkl;9JaxI7<7GfT zP^oqc2hY-8yZIygOy))CIDatSF^YUQf4J;D0q1c6tKET__gAi+`{x2g>M&9C3XjMz zkVFCc?dr&Jd|2$8e)xm;|2OLK2j2M718J{KP&o}7t-{qZ==lh1lw$^aw*L_hbIJSR zDgy2iHRt;`?7B*&_Wun|3B7(|#L&l?_R4~DrDKxQDS*Ym#T6+heP{run~pOs zLPpil;Q!+)QvA!hq$#~T=2UMH7E zJKsMSf9F-Z++3%e9P`27Zx``F|D98YcH#o!HvRc3v5%NfZRx_BwE6zWrc%7}G9a!p zAU&5F>*#cH77t1^|D)d^`uwdDX0HApfbr5FFmVAmU%&^oNPZNdEa|`7Jz;LN&?Vy< zutqF9)(f0RHRaQ@LYEdXW*Me~6uqv0L&0}?xC&AWDSRGX>RB7a2uq{9GU^?g8Z1O z_Bk_4IzS===96n|77g>;9$U+ z2ycpr%HC-1#Ii8F$@wz!JQ%V){>1Si{>=~UQw3g3V3lvq0Yy<$ao@4;8V%(E{r_=I!@N2c*{$B^hIvIhDn_zv$K;qmARbntW z;cxB-Lqy_ZFO0n<8m8~K^J0StK|#KBN#=(EWUqR6s@etW-9EdQms%g1))cQxRP0{0 z=B}Caj~)uTBJAmzLYT)@@mOLbjP~xf_Pc!HCUR3g0pF-HZb^5VKliSl9c<`O-uX_| zCh>OMt5ZQC@~-DC*lg8{=Wu5G&zvk-x%WM%Q;g)Xb5*9uy_M(tsl)jbR(%=l`P>q`Re+PALI_13^j}B$brz zIO6D#QX1**l5UXh?(S}mJmB~8{r&v-^B&8*c4uaHC!RBFe5g<2{jKd{Y`0OZKu+A# z<_+6RiCE41hi8$hW;iU_d4wEbpt*^_^*;5SJ_uw-3+zh;-f`nZjpN7u;!4{-V^_k& zTuAPek3R!JQs5JnyVk?f2<*^#k{{OTD#5HZSTpI;aH-G8baJdor~P6-b7(tIMvZ*w z3|H>4gc(-xHgu*1p7P`sb>LxZ(_~DWCH;OE_?9@xP5Sd|S~3qmLmC?Q$`^y)8)HWz z9EI`{UM&XdsGUztiNzc^Va{2FaYgJNgX`O&!Rtrl{uTi!w|fKwykpg%(4@@YP-^BC z&jWZ*)c*J=AwE4*KNso3Dgv zB-^JxTKT3+i7MtJOkCWCCNM+WCo2F9RSBwn^)0!8rnXs&9{MYQu*{_;xRU81ui zCPDDsSm?T$7$p=Z;<7QsT&2Zy9pN=vB$AC~2B11YeTEdaj1FgH@Vzf#-j`}g#G|4X z;otN49931@+XjK7KM%y;w)bykZ@fOR7PS%FRa4a*(zBPIFDx3MQT=5#uIB~* z78*- z=aH^G9rE{V3!n8O9Pz*OmCieXZ!$2*iE}G#W9B>Oyi%7DeqyK%@z3{phW|L2BBy7> zPDtDeG5^-=^x*$U`Fu3=@e3gSN!px;)^uKeosK^pFu0yEPGPgPfV_~X?zy@mh>S8& z@bYcf{_FDrp4p&pRVH}Mbup{oFf?ccL$6iQ_Svw7*#3D0jJ@C6iEA&{Q&D~1X7EpB zg@pXxOsiV3B(-MFXqU))dY|0rT;QnRv~P`;*MP_GUD}GB@x3A)R8D)!oOHS;ND>)V zzWX@3)xj?s(3e*#q^;chFora5uzcS3Uox z4H(iblx#pab8_hT`nmL-mqJ(4IaWJ6S8xVii=>ME26xV&Qrbj=Vv>4iTIQE+Jh?%` z{psNP(WOz%NPs&$*PyhzNzmC2?z9AW%I~0ruGy`oU@wY`?rV2WuK?MD{^3brwA{uau^or(C%tl>Jy3H zQr-_RcraNhTH^cL1e&PVbyT8>VX6*0zCxPcCTBbP>6?1oi51L*lJq<^26ny~7&DtD zYP@ofO*_c#VwckpY}E(zijN?An?0nv*RJ~M_Dh3fzNs5fooMh7K`N{WVtmx5q%say zyq$>rRyeh=Uk{{bVQkT$4=URhzV^AtNl{N|WosNxYV2r+id~J1`H)_=XuTd4&K>5e z!5#0ax9dk(<7+pQRtC(oNvh#a=8hCO(X9gM#j4d**8)^cJ&*h$4x?NYnU&^G>&UXr zjuo+GLe|XIBZJKYA8+}>&*F91XE6TG{s0&m$q$3ng3cU)i<6gTtIccVc_HdS;t*q) zPD6V|@ZqNgW8cTqf_m{e#9YWq+bm=S%2ag`J(;J+$C9+gm7`p;FvonwcbYMj@>p*B zf@BUjrJP)Dlguzc`Hn<5xC$ZkcUGiqgQn4h+zaCRRCpUswcgZ+y$40YA0cJ3cqy@2 zlju!HR)ZMF9j@OKQRoO1N(o-tuhqTmJ#z9X#SrkmPM!fxu|h?+)Wc^} z;^6T=J%_rr$9NZ3-s^?sUY`5ceQLqf+JHf6e{@j2uP^xzR#NdrRxii-y}oD$@nr3JCxt#HCt;AcO`~Y%nvLXOd0%4P1(=m z`EeD3E?v1m%_#L&^@O;%HR2i1r*(~|Zg*C194XgRF7gYjg=x()jO|`YVZQO<(t*#l)H#qp*cFuod{K#V@@G$xbH}bYGsg`oDivqRjr$dQoG!6S1ermu5tPdX~R}qk#A@*9ZBcH!=?G*`&|{O-242zVo2g*d5KgAu3IBXO z?;`Q0VfhwS=(z0H8Wt(!{(18z9uak9p|_{j_3z3L7BS<%O#V=eaDdv3F{dx|1;QG^ z+D(u;0j{zRt7<}Xkny>Pj4RyZ>B5VZPpD%&I7`MIkb?Ck*M2sOn~2YclzU|8xX^NG za#5?RVi$kIOHC)!U2nV=0shtJRdJOn`=24^1P+G7Rqu_zA{f#ECR&8ZbmRIfJcx7C z4Rh0h<@Dfn5mRd7P7n!&)mFAr)mjR(klSJAo%OTE@|_624vn>0vy9*N&Zm6s<(O$9 z%@nHqEb{`*#s-9fWh?HFU}m)@(HK(bo1Nhb=lFU@C5GRE6=a@a4x(1;zPK{5K{X z-PT zAi!igE>A9YTGsq6Z0RvXqe-BBWw(A2lT}!bubJ%%qW@ZSmOR?X6)M0p-bnsyZg1Bc za9!!pyy5fIHESTdj;i37v|09F@7?N%L+86)os6c%jtxUqlqS%o!ixGs(DD>hXc90# znsO~LohXbLTD((E$ZmNpyZn`B<`Syw)R&5_nL?yDuiHee8Y~`09jGy#O>V=Dmhkg{ z<8naDTO7>NYtxRM_Na`31!7`1mqoxGcjJ;I{Ny^1H2wh3L6SEfd6;gQ!X8#ouqVe3 z+t|W)MT=2kw4k)Wo7(i&Dq|O%D^i5d=`4}8oi8Qk6DPtrJ7qq7M_0F@e8iQz+QxXSQNR zHd595tsUF@$d~?&J@U(^tI1tJl$BpuCf;nmcZJNW=;j+nMEDaQ$}g1^dUITAVOLcj zInksn8GG*oV!&q6#w^hrLn6ipjUanR*2#yOe@mJs-Ykw<)?{vEqTa(b<`h6&4 zB;3`AfnA3Khq(&MsN1GY4}A}QJ6)%qB9T8uIAX<^AU9)ael;5`4r#^Turky5p+l__ zRiux&q>BIsv&|v7S&3uo%7e6!C|l*=D; zhM0h_jJGC2hYmXQ8D%R4{1X!;D+mcOA4=>9X4ZS;cvQ(B#b|J* z?yp?LcD{6SB-d)-!4gkfH!>(nU}|y@RQejFVfz&{7u&edC9_qDqQJQNTjP3j3ilQ| z?6!4v0b+<0^5C7P^0;%vgUvS&ktKWPWYwgQ36kCzX)Y6cJ(>%j~m)w}xh^jfvMw>A7H z_6(Bo2S-<=_w??`=_m)O1$mK)O}DZ2Uy284rUbjCh0c5ApDrv8XOl}uGn4>ijmaW>q zTR8b(!+BeZwz8hnd0_KE-J+0EoMT}n;xpwHGL(Ct3M!QN$-VGVvCQRCf4W)Fk&%wt zi&(pFP2D4P@8R@`{MnqerJ?uX@z6nw3P^|L5F@Q>E%C$?0Ab2apT+nKHQ)lBEiV_7 zdph@E)7TEmHbeJv8ps>|=181|l21wSz6g62m3nOIx)%1$TxP#(>c`NwiMkhjs~O3F z!PdcBT>UK-b*Z}EjP)(Zu!0=)Z&%3ZIS0!5sp;Q* z7!WE}68jhGxhf#L~8~^;C`@vQVmH%_TsMlD$NE zR|tk|A2}sP?&>B$My6HEQy4Be_3xOn;tG&bxV4ZfNaQdNi9x zS?3Iy-lgEZc2ZQ&7sg6RkNImgpCE)SYeTneTbFik%&AJ8K~^&9u{bR{?Mi1D|Lx*z zSRnwo2Q(r<8f^Enz%;}#OHki&oX>;7Mnh?E4*&QBukH&QH=gF57N6-j7XQg{{it{d zFY6AgD|uwmH-_}vJQ+MmnVq3t63V5|LKl5-Z)5g?P<}3IUDIik{5;Ntq?}ixg5-}g zoKi0+>kC?%9ZrVG=`Qv#RK?m@6gF0P0d}{%s&^b$`eFgNx%2@-RXMo;$cE|PR{Ats$h49Z zGJiYXWYm|pYG)Uso~x+qi10cre#9-4s0AKm8%*FqBz*RRH|51=rM8!->8`nu?v>kI zYJ<%%w!SX)6_<7XzN`?I4c$7sZIon{`IoKbKjigrs|#VHfXzFR)~>|9?cQ{@`9Qyx zFz@UeqEY>qWs2r|3)NZ{hZKF?6XgUja8iv(?o-94572~Y^&-QG`Ne*p7dPh`O0GhsA^&9~P>yKa*!`d$pR1D?2s zLJKSm?C3e!tqwYeKQkkq(1NK;7z);!5RHE%b~2TdZ0|uU1~RGP1fH3XW5li_3NW&O zvAQi(gEPp2*hk=uooFe$17hQBfMW#KnI_IthfBGdNR12M;TYr&Q}XM5-zKxb7Lr9x zlJ;cHyPs%U!;f=ga=yui{Nf>h!oeLr#dc>tTgiRzJd4ZJ zu*QVegiX-o^olmC|7SC!GYsw#`_$>0Q#7C`2F{Kt6_HesZV+szPqfuD_D@AqIf&P8 zSZ9o&jbH9z5O_Cs)PU#v{`=h+}Jc z=wR^XSB|wBIp{Sznos)N^gk9+aV2!1zmG3#UV?0Tdbfkv`8l}EMYO&SCmIU)EH^(2)Yex?lcqxT*ExH-E zNNV|$2SiX7Z-l!%kUtB-Qep$;t?O$x)aHE zB^T)x9Vlf8xG1Y7Y5gg!`9(-nCZ6roJ~awrSbie#MF3GD?&^)7-X}fX&Grs6Dx$LF z2I>q0|GjGI!`I~G^A*_#-v>xe2HDZmX31^|1xc7L8;JP~IgfbD%0JEx;|jJ*ZS&Yl zU1Tn}(NW2b~`g{yPq5=VQxBxqG+1A0sKevLQZ@U5` zKB}2GjnwHaaDUDv9o$`cDy$YHok|k8-Q%6?dTb2|sXb+rTLRs_>;w>HGyjre*69Z` zj|ixr8lF~uR=f3LC}(7An9{PGD*8+kw3OF9IoJ&LDCG>Ra~8`_z?-3)Hf?|zW=C*F z*8~ZtLGjVFmouczLcuBMI>@t~OqRmQ$JYE2Bwh@zm+DJtEr9E`IN_XJ!6tkBiYENVB8%UO@tf_5} zfybCu(O=dfPxnR+T+Qn&&nD*p3GctDi0~C0ES_fI z)Jgq!>R`lmBnSnwoK{CVt-=vyj!gO&yA;uwr5fMRH>_ySPLulxPM3PnTuzALjw2qS zQt+qHVe)~RjT6!6Xn$K(uAI++f3ZIe#Fr$u*{2&8Fo6`zbw20!?HCURDWIEiq1eL= z(u%!9Lx}dwd_4s|l39S}$lczab$_J-Lfk6TlaIUQ-f{y^8`p~J1c%r@rik{R@n!RD zmKKo;A-_I>3BIT&^V@I-H!ea&9?~zNAt6MYTkAv5q%J@`j_6u74K+aK&$JG5a29;d zi%@qr2MIKbVFR=JG(UHW{rbcD<>z73s)+||ez`c0*q$IZs2Cl;uj0^Yn! zsRyVI{_Z8c*Wr409fR32Kjp75oBm?mKlbUb@ueCIqf{W|wll9OWxcS8=Xrp`^4ADj zUNG>62Aaf?v!?|mN`fD||5+oWJQ@n7us7pBI`bw^^QtO+svfpM{gy1FTTigk>sOokk<>do;D21ZRnq!>e4^b;d#=s=$m$egHK>u!{xp z!5V#m*skBs;xmf<+zw1fDTQAu(@WRoqkN&`{kSC^)yZ{!)%$L)Bz|hRnxL8JQ0mP~ z%xF2*_nsoB>DGW;;AzD%e&Rob}4B8%*ZZtRLd(*UsN=|dlj!{uwL%T*iRqu z)G&LOh-IQ1R<&nUG0DN#X~Kvr`+Tni%zA%*+`%4njw)Mms}2||_OPhcTdbY>M!dY? zNB$7irJMC55q;6?T@Q^KyD6guOmq(M?BLrEmTNeD*!K{5IHSZ8y^EdJWA&mWqED=x z9Xum}j4|aQ_2+p!un)Kgd$egBQC6-`3|URTI+1jq;QUrsyvGKiRNxV>K%j>*Iywlp5H4wn$xTGFz2L`@<=pn zzHsIQC6-2jcnGq*H@PULF7iz|tKIsMA82+b=IJ3pjXTJyrtQ!MM|baUeYf4HZu0fX z4}jVgxV=%Fn?yfp@w-b{lZ#E0)BRI1QYOjl0cx&?h{9qyS;91%beu9Zgz= zN{ZZF@YLS8a$#1<@6l>f#=@m$ug3F``#0-rp6;EupYLz>#qK4*4hL~B4UKo?6&RM> zpxnpl&l^b+)cS#>GV;gpl;Z&T9n+YbZeh{VWDMK_e|oe?_K!QbTIkwlopNv`>Wtzk zofg{Wp@^D^LwT={#hvQ)5b{SK1MGg-K4r^ z{noDma_Z0N$1c}VC5jsKgsY0sF>gVB%Dhc?{+!`Hy%x?ch6Fcf_t<73;nO~+318=d zKdk6dTnE*H(Z9jDtEzA;YllwFgv8IG5#)MuB@~qC)QK8e3SgDp>)VUq(q3} zT?Fl^X+5Q>oa%6=^kar@MS&lWe?Qbw&TG4Nm2^5CT#Y3NH47)WxJ7UVP2~Rs`(5(@ z4U2-mXvb@MGi0>tskWuT-!XitjRd(+Pgeq>7xJH%Tctdf4Ke@z{(wk~h)+($bwOEg zjwrF52`?-oO{#e_HJ?7Dy!;9stk6`(`j9$NG`MPUSgv+HlH2GTVJ|fHc|PXZx$8%9 zYaaWklGNCURphjY<`y0GD4}+Ir_`}?bV60xDY0nd#9D2S$Tr#8`XDm1;@b8S@!tMN z!HBHiyr}%j_s zd!sbeDE#gcEJpj4aw8^zN0}FWou4WO zt7v95%^mPGd&%LLuXbGD@L4hS3f^GZYUSv!BXl(VsoIKC_^(lvD98%g)Mx%zdT>a5 zJ43mpaa}CcNzi_Smbl&y5g!GoR<9WfZ<4g_({S*ivui^zen4_t}y&VHxGv;{16?-FyAIsKCd#~R>H`Gx6HW~l;m zy7L?t3v0T>^BGF27rD^mb^A#Xecp>ex-V&pltbC1N#j7xz(=IIclK1YUkD15ubYmH zm~M=wL!FK6F_QAC3&=r;`-A~MKI=c;uQU+wv z1P&S15OF0_QGy)uIvBipcr-^ZU#jof)+>KwlEIAusX+E~)Lw1tR_pyP6}5vXe!{5K zL50D>(-d2bqy5l3aDbGLOl&s5 zsl&frq&d&(awYbRsgF8rW%oi&z|GZt4GlgmUrkYpG82CNDgxPO6BQa0oGd_y%cjeF z%WCmr(Uzu_p7|{?$H!CBwhOOFw%^LK7eK7ZlQsp~tW%d>#4nC3@APKUH^&HX;4f^v z%}$kJMOK7aVMpLJAAynGg+gMr_eu-hTZYXboBsLF?wW5fDsJ>@0eEnz9HC8Mfrl3l z1Zqa8|C6Z-7^v=&w`WN|=V`D&ypu)w?DV#8f?d=WlCVl_x?=t~om*coAT9G=m8c*E8tbT%GgN0UaPAbb!@#qGX zuKjubJs=wBjOJ2Q+9aUWV(Y_NCiq!|o#K5}8ba&^x7zM%PUT@Pb@mHgu|9di`K3$* z^cXSEmYfFl--O)NHgeNNLoTN?T&Z%f(D>vQU)u;cy#k8QvM)8liOrrBxEjYa9`>M4 z6f`HmNI(91giseWMp3-OQBJ}e5=Gsx+;VL=d`RPom#n;s5vCJ;Y$&pupTeQK3ZopU zS3ZoOhE&>66vKwn`(|v0`wuik@D3XuIv>iIe?Z2u$_H@S__-6gFOBHLlvEKw?d6EO z_C+*6K3Xa6jM);CM4ul4+0lJ zIJl)wP5fNVFIt(=h?xuAfc}N?g6MTFI-LnRi?+C!_LOQTmyg#EUX0-B{#N5WL21nl~ah zRBv+sq9%6_pT5foS=&nwF%Q6^c7J$g;w1-NBkE(pRkL^LX}=W8|7`eJ{1nJOZ^%orI^+Z8C8lsjI}OQvi+3Q< zEhzvs&0;hM6-^{*aSX_6YVgnqe|^5f=yj`#jM1)9SEqidBh;Wa@;INc>4hC^E6U6q zUC2nUxf)4CXoLu${c5~?c^Cyix}4@{SN?fQfEzkdKfqMpFW;s4Ihn0U=cI1b8~>qH zwyB}ezyn6a#PfXY=vHsA^eod7=syYhs}yhf$M9Rwn}e@MgtA~>2V2S#1oKau2(LkA zNSeG}Uw}Y1BdutwSo;i@XUn!F?s+>le#Q3GA;>D}SpZc>ao^B(wP>Zo{gAP(NAVmC z&Q3sT!-Q))GFiO`3;+YxXC^zs1jPSv;0PRu*XCCN*2q4@4sSXC0d&XPp$LkW|9%32 zPEkj`?+*Qk4jt_FRfu|8!~mHgwjsWs8|nWKoE;y?YG2 zLmujEm*ywHTcq~I21!}&GW~-fTTEJ_J#4)zYXMb^8j`kem!_uw!>bnXaVO$d6$bLu z5d(o56x09E+F68q$ZLehDGk%0vzT^A-_ttn|6u#d80^TRt0PG|Kp(V7G|_2?|0@TG zSir9bxqTF7vK0oU{7Ys4e=I|RA7&Q%7qLL;Hh1v-xJwWR z2_Og%-Ks2_{D*_)TT^eZu}*`MB7a+8v?d*LELK&YbmvZ|G~D&QeO##A*{W0MJ(T~1p)2K+ zuKbw$|LCq$w!gKW<*cwm1fga>r&Axj`ft^|U%g-64%S+dXek0`hsXb+vduSA5Gm$e zFr#!#5!MXXz-zXP|F;zL5O4hY)?+fNI-umyrwtLyq6zgsj(ioYeO!4~s(~Z6BeW0t za8&D|{-+(EJxR{2XU_-*IY0tyhlA#k1poa!K?!OuI3}wF#)Eu1(VmY;k%2lYT5Mazlk!`Bkon}bzZXq1)gi4*7Du){{t`gD4@AX zF7h3hYMx@)r^?a+VR5ybjk{*zy|?lC_g&q{Pq zIjTPL(!2}lf10}pdj^>S>qHC(dYhenY`6U1I^)xkdW^2bY!KG+eer#C{wLPG?@v}% zLbFloz*rC6m;VtS98xyoxu@;~^l;6&eYNI)0)3C>X~(H)A|I{;jD2Vy^!@Wc1>;Q2 z78T8YK7R%31??gx{2Onrgx6lE%EWYJROg+4>=4qE9Fiyqi!O?lQzIR*wI)QC#AC`! zQIo4Ket(A6UcA6=#MJ{-Yu=W(*L-OEkF4|nYjAm%Y*6BXHmYRi#_NJkl=_lL^Mn@f zA?ar$au62c3ZIq*kagwla7ky@L)mv@d;ft{5BzZZvT51A6?9 z*@muF6BX@3>nbSd9klkl5fzBSj?m5&U}i)<@?mUZYMD^?7`IO`(}NBwarYG=+Wa$o zmR^~c=aubm+;OmKyI}F!>$kL#8~iokUkW$weTJ$EE2(r}ns>2xp2JJIumQmAQxpKt zZhDnvPrp#UrXre!>(jcsUBB&JxXYudizHwD;b6^ZxvvfOt4YGN_CwBkCeu(13~Ypma;%_Ea?wriga`Cw^P&P zS4BC~aU4N453{Pbv^xioo+Nj$+(pqj)uYlU5D)>qY)`M%LmjW@@|XOH`-)>H)Yxsa zV|@q8cDN6*LIzjAre*&SgV9~y>|p1r803wg^8{QW41*dZ+W%uO(*W?F7ex{O)1}HPuyHC!wP- z@536Xw;3MW9PgQ!j~76Ygz*gs{_fzNzX8fqTP+I>8Sy(Uq(u2uD9eg#v#LGJDb-2~ z2}_y9-&D*)_deP71I196`*@!R0(f-m;Plu12K360_=u*da{%%SD`-lBB`9%-6p?jf z)f7MY2^{9UZFqMIPRszBiX)vMM4AIS!gbzt3T=TkQM#S#hPIL)NeabohQ&1FVKSQm zR;{xzSWR_#d5i=hq;~z#SJ z{P}npEzv~E_xilCcw`K_^Pt%F2R`0Dl3ge(fo3XE!xKO3^puC5&fLKgK%H^e#;FV z#3|{F@M2iE!C|sN5AD*hS$**C=Ub13o|Amn1&F)Rn!Mo}^;G`TZyZ0q8I#}>oRZ>{ zs-9~3KH)1&!S8>~`{LB;L~R|o`8nx6%0?-rW@ZB|<+B>+LDf&1_aRyEr7&J{f7o93?mJ~MU zi>>>?c`%h?j77zXkGZe4pEJcJWPyX>lY^97*X!jAlK1ETv%93#>Q?NT!wKv0Pb~zE zFqiMXW6?l2iky>5ze`fzPC`O{ugiJyXD9TK2+j6*>kkeS()4O5C3%UNx;E({`z>Am z1p<#_P_@g!tJHTHK(i#9gj#uE>iE+0#yb~M3WV;QdIGLa2cIjJ`K_P}TdhW6;l94( zFD!H-j+!g`7qr#8ra_c5RsrKKtykfzz`L8IbMnAjo3aNa|LA?NziO*YhT*h9tqkMYGb6nw;!p1=&%^t&tK1sBJ0h$4EQ^G| z%3OO-Y{W^}vH*(t+AAA`&lHbQpN6NneA0Fatwg!C?>sAcjHLKaM$N=K_?Bw5E4PKV zOK)Zmk=$PSteYB3K1OiDOrc*_tf6gv`?6B=IEDU&QaF7CM69*v1n?TJ~F{|Ifko@z-cMhBW~Hh%sh zX8Pxkq!Q@w6axv$=^u#>^_ju}1}X#)OT6qX*4j+^Ah{o6hQNI~9h)NXHTANz3w$Oj zw7A|!&#RIa36k%|-rTA)nMNH4k;+ka&4XJCi3L0CUelY$H#TLX=rMk!X!J`7U7rgy zH+K}{V5VEg!e+TTw8^BqCZ1j=3CB}=V(i>!?!t-9>$YEB6DvKJw#}11BC(j}h@g31 ziSeR*mFpBvePO3kqFK0AsL$a=t|0#i) zy7v~^HT*FwV{H&pz4BdZu?8dC!5Cib5i}k#yppf3H}CplPl`ruShrh$ad_E$>uF6o z`66lf$5QZ!tKsQmfFp*DHIZq_7Vrg1gXn98|NDZ>edd*Uq3~icf`4b9&|&$LWy7Sj zwyS98gr{+{l}3a^C5wdn=n}_K-5%fS(MUcbBr@)YLy7%bUg^MAZAC+|G`hh(yVYa) zUrX5)o94@hp$eotlio~{M{I2bEdxlhlLWAtZCP{~RcpnrV?xW1b%+}F|JG-|C>y|V zC%p@GsQ%KXo#y>va|~m_UGU4i1}@@atYNz`OnDASAPJVqJ9k{B#5O?24El>4mPsCG zmB@b#>9=KAR~a(9bRubdm1vasogmax0=!A`8h8s>YT{I-7X;B_oaOCN>(FZwcyIJK zz~(8o@A&b!w&=Q%T=D%`;O=7Qh@}pGI6v!LhhgFF4T(WOv&iZk$FY(1_l1VWis@ak zvs%_wsJqvOokl>RR69sx>-H<@BenrTUv6=3K2JSJqu_g7auAO-6mD|e{McmwW=`^t zMiL+TCgV~Lr4B!fSd;5zr)H9{j(F*bmx|%Fpb3o|bb~5lm@M~VP2N;@Z%eu2p;K(*20$zdn>GUaO6PEw)cqC{}(S3|%?MuB1^R)SO4Bs0NKv_J8 zfnYfSto(}lnQ7STo3hAEN1_gc640v~uRh}x>`c)9K?K7M9>3I$hn=m3;4L-0IM)tKEHSvYYsXT{5NI$#+jZgIE#oR90;O zD9NZJV?g-|fs4LDCTEAMN8?{fGM&3)q_(eXadZ+RQG4QlATfL=Z@uGDO2Rgcg-b6m7w^stK zJ?Cfzh?$|+nBAK*#V-2!>Txq$*(0&b6we&CLnbk`qN3lOs(@DG*P9i(spvO*T7GlW>9gU-YLhp+@9dQN*QL$5HOCG{ zc;?BPnI7uo#5a8Alh`j(7A6{LW#DJ#v8{i^E(wTJ3=4*0<^s9O7DFotFTqm-@>FojWg}O)Jv%s9Zr|X z5m3DJzy{oi`toJnr;Dgbrx$gIcJ*waMq@x@z>ZNv-S*LK-SA` z^p>7>FlT~U3si`>d&xj+zlIY;8NGy@!<~X!L7tLAGD5=6uQHfW%9&Thtr|ZoIX(@y zJlz(P^&U;RXj55fuWr1;od1&?=3iR4dqr-Rf31kEt%3dhpi;f1;!hD1i0@KUEcZz# zYH7T;P36|&4+-_-8X;Q@x%3AuFIdX+FNK{14)>Irr%b(yjRcRT^}JTv%lgGO&lX+= zmP5KW)9+2XW`AW&29~x~Vlz`U8^rEave!D*kW8gh40J;#)v`PPd6gp#s6J0 z#R;=JnXs@nwP)AhGmcvH=>n_C24C&O3B4w(Z%|_1vD!G?ec{(a+G7*TcOF6uB7MK85%#v=loF}c= zpU<5)Ewb&f_b*Br@vfL0mA>Wg(PS%Jm-gdgXHf-+|Dh?LGM^I3C@L9jQApkU`n#2a zX9h)V0)6{j41IQ$asA+CI6DY8GS2g=P z<|eIeinZ_2+(fth@ikZN*Vy0*56$!eSI1cc@Rqoy z6Lu4qsqf!u#@&76Dke0)A&}U#T<^LF<;xJu#1tBVL`ojlcEt4SR?Q-B;~%3pBBn*Uj(ymgi*Do;xnqibRuAGxFFn6JjiF(6N_w3bs{? zN;lO}^&*JuDuh2tA!v<2Cio^s0P2d$ zMGc(NwLRwoTi&5z@9B}tQ2!E*6OzWx0I`~x?ek3#|GT==Fnf|*>NzZ_pG3T#<|o_^ z!0kAkrn1w&$+>O$TIZHyw!05GxM5xNgLkv-MWp*GPt6b zWAv}T3QM>)zg|5_j)Yo9`(yZyi&a=0emad2O%akF%>V)YBXT#tDIjxFiccdLK-Qj{ z?TlAei+1kiE_M>x78tJ<;@^G{b3mtaS2lrN>*SVFO|zd=U7m>DT56wBT%ruMJFCz+ z2kA>7xY3`+K7G8`0kpl1)AT5pSj}L8e<)eE*o2sm@ncx!K%l;`z{@>h>AdYhds}(a zi+;OVt(CdcBZ2MJKi9;GWjwjVDrS3J1U?_5Wid8eKd_>L@@UF=zeji+2SzCDD5YIB zHw@26=tKmT9F!vY`HB+oT7uUguwPQN*;6JA!%@n3{vMX|JS(g<^=Z z(>>i@zJ|qEVG-x&$!R^=`slmCwrcGK7lb`M&=gM%gbz1=G7^ZyNMvN1e36;!&_yKP zN*KFKSBj!Qd9SBqd$;M&pSf`@#wj8e&hK(=N8m%~(Qrk&U#i05jF&+zuW;{L$7UDo zm46=16fazsP%4sTDDh%Oz#D`#`OjC6Y08TBGpQJ^>}QYK;V3js zY4Bc_lx&Y8%bxl3)gG z>%OzfDEJ&HO#a8sH%8yX9j{H|Yw`|ND7+85Hl$!Jw`Wdw=oc>NqiCZ@eZOz(&^vuc zrJRv(CZW1);gW8&sxI4IP=tQ$Dl7+w1o|mLA7&DEcYUko%KJAMHqTD}P@P^hpSWgF z@IoF6%}SPPe`QuOA035S^1ij0htz1k?9E>rLCU^U#bN`$0Hq~~dTBP9Zg1+l>4+T? zvFGfELidF)cU>tAl$Ng7*#|NFg<5eH3%F_?)XKN(r_CBB5oAp?fvYQI> zFCLK*u?(@)Ufv8%p*`nR+mpw3cuEFISbmPSl4?fKz_p@8 zck%B>KX-*{NW`zTHg-p@>bXWHrFC9xWapgTXBLSwH>d8F;n?mL#2so;AD3~5O4_tv z2Q$sWB&IBEwxSHUL+u*%D+Wf-@HdLu_q1L+bJlg&-;fNylA1zi`GsI@J!k0~3pFNe zTh6zlftI+*qbs7KP?nxD$8qeZ#0OKptjDL>m8?Nxu>@59o*V43x>4Z8<=o)b{fY<* za`Sw9SVPY5fUe~IRU)oNS9j>h9fn0@_V2eJyTUkU;s=bMAIet_R}7Bc0+a0`JjE7k z5p-JTxzOqJeVku(sjtPar7|k>bxQL(gEkf3wCRiJ@A;iqIsd7g^-8$nKia&}mh)Ai ziu#19LGH;iPnNg{y+=-ohDK=v+y!{GxE@b`S^F)|QR^k1`yx`IJ8v{>@sIoGX)`Zc zhPa|f#J7$FU&YPbkH25t8d~WbLJZJp+u=A?f0JoYeeQLt*5$vgXzx{TXMkTdIxr*g zTR!*spI>T0;WKPudgDKx3gN!f7xy1sdOK_D@9~3Za^}~wV_laT@fVe3 z+Rf)o77hQyyDSmyk-_i&J~G>N?so}7VXGI$S=ncfc4-^v3DB?Kt*vvJM{uG6eRJvp zwG>-9h#|Gi*jwJ>%sp++aP%H<(LR=Hw0JgQ<(!RVdvwL;nzL=jb8{BAo=m5je=o<) zBp%;-ewE2lid;Hq?>lzQBgdY0++qG9U2(&a`EYO_`wu}}@S<8chA8#QpY+iuz7t>n z?{SM}e3F`elpneB4N|Y69i{_fjn4^gM;$vv@bf-u{wy^`BbVbx&NR<1Ug*Cd7ul+gESwb~2mvV6}qV zt^HafRH zwu8s50)1Nh>Oj5w#fIm{R-OC*mi60TAiPj{ddP;8bEfsjMABiG`H_U?Wuquqpjn19cl zf|H%k^9AmFF;uZng9C6eK1%X`(e_HkZrVQCWZCNQr<8wuim^koTE=VDC3xA@*pO@4 z)V0pCdRG0wHFBD>+tstz>dG-WY<-gP3|>d#a|}7Fb7kQC!XfL7jAuV8bX!uKn@)6N ze+7Nr)d~kJ)u$mFh(FZ>cH3n*$Ha~E5ju+k_v<405>XJ@@*&k+cjia4D>+Ix+jIB%iW1GM zc+Wh%6vJ7@*Bfu(J$NAP?zkXT{AA5ULzZ~xEd-3>by$1NMa$0w~h+dZZi8}%9M~^aV zabC~*yG}bq4g}R4@0Sy0K5bA?9)bi5%@WBi+O8=mA)TA^YpLJcvIo};s!QZ^&E>f8 zV9tCSr(x{DYWe6hN4My^nR62dA4dwvAlmE~%XGufsg<_$l*~j#|3Myff3(&1rsaTV zWMNK=20jI4#w~^jXeF&=Ju8Qw?&hO(DEsg#{h}7vR$4jcnLgaxW*?07@Sw;7637HgX=|U<8Sot<8zXM>a+A z_j@go*VB5fd39V1-Q&?FnfuE;5l9i!pLrU1jqxlL*rGID>ZWL;lWgTY3(tceSNo<6 z>kYOAoAWY9SrMJ*WgaB*>uAhazQP!w<1VQ3ft!k*Ut_pAs5hXnd=I(9ay46tfY?SklvVVoT`83lfqFin!li1=qN za{V0SJ?=Bb#7$amb83;RjemlAqf-P?Kn*Gc^U0ttAfCFJT<18CFpK6-J}v)B;ELxt zV5NXg3pgZ3ms)|0Dpv!Ot`4*D8fj@G$2buD?KFH-!>1}qY!Iz zg*7Rm1{rzgvu^uh9fZJ!;VG6L^WTOQlMBTVD|L+_LF&NRZ>vy_kjy&YS(11!hQHsl zKD{b6KV#cUrH0mQk>XzYe_CTFRyCQU@a_{Tpxy_oRgTh4HJ_IO$!4MIqH?%yzBv7| zBJV2f`s9e-xz^cp5bASqz7Nw|dTsrtB5{i$R1S2YB1Pc{% zwK$Imy1Zf=tolEh`-LcL)|v#$$I= zeK4&yJO!J=K2%3)FDbhaVinzaAz*0Nq{K6o&VR;-Mi-ayoX8O%@1zON?%hv?a2 z*aa}sa~@(3HNi%K$o{2mSewlWzy_ejU%7n2Z@D^=Ex=OY43PE4cttxZ`$oMMHxR*c-2Or9^0 z_|{WSEENny78FB>*q2lLJo?hBwqb&xFeta*?PVVj#F65$X|Oz;w9S>k%nl~L>}a8x z8uY-+N}W1RDW@c(0Cr^%rD%(iUQ%xngvkBN?RF-zm}kl3>t~rCLH}YkQ0-3`=1Bpn zKSIw(Y%;~H^Z4@2`D89T0*{@A0hjciY1lbX^WHr(K5!d8`qA4Zw{4WnDJ#PV10Mw< zKslPi%_f#KQ-xP?B`bdybH6WGbnibdNm5!ifd}A@Iq)23KZAaSNdn$Geuhs*tfS+s z;Uf%^XM_IQA+QZL1MrLl0w3BOemDdx$jFKbZXFa)3#Sxd`@Zmu6WUNo zbw8qWK>GJx`6IR&Rhjw7m)mMEy0VwK891we0JVjdKc855`5t9f1B1$K>-youU*VV4FLcnqJ%teE3DnK0hpn%-t%C`(BEG(A*FmoUT)haU zXj@i;^c#YDTKcfn8Cx2`2#P6K9PmN!#T3M2BY~oHWHQ{OEx0lclBBO8USSw^1tD}E zoF6G1)r*T{1&~ofsZSwAl;L-1`O4w#INx;jE>n1=!5yJ50+COu$^W^GD9h%#;9NsE zwHhl?=2<9w4tky|M>U|T2Au)IKbW=->{MVFp9xd?@>MUD+&&AnTJjK1c-uj}jaDs9 zyqAB9k8ZLrX?PYh>A@QwI5#dgIT1tVWx&K0s-T~RbzzHxnY^z!i6*5~d3I{c^9e#4 zA-M!gL*-1>P!)BKxgFhV;u`c!AqTI$ z%q7?c{=Y6Wx~at!3A+x{fMN)@e<-t=MPTkH3kpE}<>u6Cv@iwmX2C~ieF_aPV;$## zh3122X2RY;Ooelhj)Z%LO~ynrFP1q?FsQQohCG@!EZYz?r3u_8%(HD9r@Co{C+f<1 zleDFK#>8Myqx-ip4@A*pJ`{aRcd(Zl9>q`kSmSSa;L94E$$JNPLJF1&tTo0(t3gh# z#evfOc2gmqKomVZ()BPHMTFQAf{-6>zI*mAEYCxQj^R4FH+=@^vgIrvegCK#JB_7j zBO(CG#MzL=l2#*0AJ6icsv4SKQkPTD%iyW4)HE!5%ic+{~_Xu!?J8&@k^mU+@Q>XY$OBXe@QrFXv*1(*k z2%jm|$OB5+1=TjF2rAq?)4s4t6$fJ|z!R&i1D%O+p(gTt(sa1lq}L&g%KFKRxpUg) z#Ct5!kj+z$=b(OTt`f(Z=yn+l0#He@lAH!DZomYL`6T41q8s<$RTUJ-jTIF$$_EM=zF}Ocq;w>u(J&x~Kn1J7H+hTJluJ6ss|8^ff2PF?OC`%w{ zfXL?kA&s)Ys5dbck%Uy%4wuD#P-xBJv0OW|FMca!k04Ywothb;WOST|jwZY+^N6q8 zyo`*$B*Y?>y;j^!nZ=mc$O%Ct6W^&kk)_68^j(Vf2unG*RnuS!($#bGhM+3@DHJl1wP8t7Q#< znc6fkLRyJ~;Fx5o0LTr19lX2H)hWh@-vc9Wr~+P|S5nD@`z+%!D;_Vt#EX}gUOysS znpdGK1GB$Mc)K7=#ZzxlG_oakKy&uDsI%Bt=)Q#F$U;YeGI8VDZ|_Cng{jX>01Gi? znr6O#qs{MW?F+Mf4zc{CmKxVE{$(X*D2OURX{mat0KYLGB1jRYe5S40>?fQ&LKeXu zfQsbd&9I!bMX!R`=Y5}_w8eTD>hwvK`qL82Ln@*OPvvIh9}EZ1#`B{&7dwlxNhO*S z+nM+BS6y|v!{KMtfo>dj3qs-kwV_7#s7pjeATpgkYSUyoa-{b+AJ5MOzKULO8NoJb z0=v))pX_~A80vX|BfyF0C!HFI8SbW-QC@F<$)#F}o7D!oe>A-Jrrg*994zxmM7q5x) z4()<&CC`A+O8^m1V6ou@JMtMJ9N3o>k<#&cYx5|(gZ>o4^-HB@R%NWGf5!r&^fT5I zz1S87ykb2P41rr>)f$lvpiNhX-K4ui*^fNw16P=X0~YGDPhY=9vqXq#NbRu zZb2W_hQ9|X0!4q!V%3M{Zen=8;QiU@0DGxBR{?Ptw0zpNLab%2hjuALI2jq{Yq%C< zOE3_6&d9h*WTS`>YG@(@>CnZAJTM@Qe+aC@8N&yd_gI`=f#AltOLv* z0M$V?h#n-CaS2wXh`+P{2V^13*O+PWsX&%Ei68o1oDxdIvICgd3mGVJ2=}s5gJz2g zYE-2`;63G09k=R?iG!|PU)!MmfcB}DErpbJYBA6Q5g`aUy(oRVaPWoDdV~_G93m7gPdQuH5)62o1tQF2|yrM$S~IC_%$zZpACADPFe6l-UGiwT~)%d zsK0u}06u?OF+Jhlo;6`_aX~06*ecT25w$q|_$}Q<@J3tK%Agf$~=#htde2wY4*B zVrn0Br2}XmPrz!YO{n9w#`l;6`G@g={UwO?Y_@$tF3|B?^$$}}cjuEO$_jPi$(Lh< z!o7gVM7?u!JwxGXDW=s50K`Eh&MtgHT~38|v;|_Rh=3R>KO(<*oymcx;j1I2AlVY7 z7%@C%ZIdIOpb#YcA`nuheOHLBEF_bEMvxD!9=EzVsYXMJskP*y#7RtEhii+WYk_$B zV^|cp#~xDAk>GA_G2H$FYSs;whmM}&1JejIA0K3*OH97@xK>nMHiW3NBHqZIU;VM6 z9OJTO8u=8&r-0rXeaE-LE4R__>+V4aY328sAsM~NL#b~2+#$BKAS9#M=e9l*4s+3Q zQ^%AbCwya_NAm}6?nYK%<=`ENzz@2d>1bbYT|tgZ8U6u|+c`W@rCAe`ShFYC!aAk^ z*I!0%e$MHpI$|QxIYOodpU$EzM!<$MA4uaNogE%83}Z{bv{wKp4NT$jF=jXlUZVW8%)A*5=^XfRLTcFr#JXY-?~{3~`>&E|jTUUL zG2=N4Uje?kM9uu+&Fj9hzGv9zc|%@ogJOnU)@N*A2;^#Vj^P!dJe@yJ&r%~ zdDf6O<%5=QCONOqBn5K&xhYjx*`4&B%7Ziql07K(6I4 z&Iv%LyrzmJ;v4BZd*VAc6nqM7ctG@LH(q{w?@q;1Z&BkIhfYJpw2%ZoJbX=3C&8e)rrukCl*8tZqtBGF*gWO*rBM?xn?FFhp9O_8Q% z^W#@Nm`Q+s00O3u4E-B_W@2X1j#zzk<-=@JgdWo9u+4%An)rnEas#-NIUm$7_*(03 z?HQB-`JU`l%hR4e`5lm%_30t$Vu`C^v7vu;>?*V4lhFkHj~{z6wrPTKPu$!&t}>pX zBhl{}U-y{xumR7#2crT{k2~{vUIsmDr**q&t>=laSY{5paWR#uc;uoM?NG}vyNZr# z+dPqN5THFqy#lHdebTp7j3u0&=t=a4n?I3tWVKij8ws{!?XwSf+QR_A+_~muna=zg33ktD`V3WlgM!6D*3I~b?9e%+WEba zaUEV2a<;QZn#lRQ^p-!|q2&EQH?|`|jXx_JGWyHlYR2Xf4^7-V5#KW-=EMiAsfNe1 z0&po~H$>b8diKfQ+0c%Z?WXTr>4cKUvRIbPN|@nkz_}A9_XH&cfh7yDwDH;db52N@ z;&Qi>P)qOrD?QR0i`q0RVo2_oiF&wR3sh(e7MNI-?QbGK$7XyXGO=*>3gSqiayui2 zD;4;jjSdS}2E9zqgJq=HX&;eaP7wJm<56E)Z&GOz--o&vEPiGb*ku~-eH=Q>?FOnb zeFP{X{@S!v$WH&B`8`2Z{IW`TlEn@u@5H*Cx=fapud8q6Mv5JfLkI+8`c0Y5(xl&R zP0H){3fhOqF;|qLUYy9DrNT(Km=b)&vsout9m>)I`hIgg?h!W#6J~rfiB1q)4hqWOr%#|-dj45-@Hl1fD5X`~|B>B3) z+?v)rmI_mMWoSz%up!bpj1M9Mzof@ja?uJmFt;(c+lLhxKc*%z`W?@{_?S#B?@Oys ztj1G2EM+Za!6~8x%xd^x8H3NX4-5x%L_)u-bvi&o7|=ieuy{tLWF-~WA9OZzPr^W{m$>GKjzp=jRAh&z{|eRFA^=9 zZ8)Ouom5NQ-al%+GneVF7a5>o|90}Oa?94Q_k(`NzL%J+JxMl}E@f29P4+!jI1o2{ paz&W|3o``B<#ya5P=uOea?8-zAT6RIf>fm=#04tSk=_wlNbkLdwS)wvDJ@7{Q6SQLFF~pj zn)DWs5?X`+0YV5#{;Mn@uAR4aVDWdpUJ+uAXK>H; z1-vq3|7&ZnGz6#Qf+JUu-iyLB!u!ff_nN>+_tvtuC6ypMTkk1KK^;L&gSM4c8JnFO zw5OyX9qqrsb=tqN>;HTGpB(;Y1pjk}|2q=+9u~2?GsEN9{h5XNqZJ^N>g0WpKZ9^% zPYT_ydo(~1cIUH?!#=FhKgt$oc;wXimwXjC8x;QjyrC=%z{ z4fHT){3qTK*kG}A5p)PSUZuV@nR=Ar_b%uG=BSx4f_xby3Kxf+dD1oidj;^6ej|`9 zQbFh>ew;v?m$k!Cjj^e#5e8%ubXl=dgDIkBLrKq`aJY?j8M<^yaqbGBi;>$0iKHwI z^s%qjGS|d8zZdQOVBNer)LK|;*#z%>lA;s-ZEc#~nRHa}9;akT2N21iZB1x(#eRFg zN(thy$TVSs+#h|XEoxfgd$iH0{_ORO|APX*=AYACamvhb)X(e zFot_du8UswWn=)vq6I4K7!$^c7GUAja2tWOvN<}} zTNSc^gMvWK9}vu4eHUAN&ojSh^&Zz2*}l4ioe|cL@XEq}d_fh8^q_d; z;}gV(fwj4=fDEhbwT({%fzAVTfOtO@AQE$-7e@w+BnfNKa(utaX|aEer+n0_BRS{f zhk5;?qt>wO?HFq{I)JZ#ukl*0BZESsjvH~(kH2PE;&)QdiKKaNQ&blVAm+w}xOilS zgQm=Cdb9hTZk0DR#!k5}XgB-YE(h~#UioE2Gh~rxOcH81pUG-QH!uva%TtV9pWGQ& z*RSbaQ`7`Vs{UskLq|S$*S4Qjzriq zYuWgXJnknnD^)2x&W>WBUXRcW2CL^)?`V^k?r^8V=%Be<<2C@A%S- z0g;IrDxw(aXUP-_Pi7jI%0c)wzP9v{Wum^R_AHZ+KI}Xxd?;VhJGOLdP@y9-Swkh@ z4dI_gGy=ivuOnO^ezHt=Ml*CZ*RMS*05wnd_i&8qJotxkaqw9+vzn_ZWTMBP`04R+ zoWWPQsrmv6h58+5uU_HoS~J}toOf$LdK7k~qh#sNAk)jCo+#I=Sb1oUCdj9>Vj}_s zja6KQVSzK4NtN+W9~RE!or!RyQhnq55JmD$P*#35 zhh*3hRxu2sRLl;WGMax}`J(__5N10fZfu3v-41)u)2t0n^|9$QeKN5_X|^kJ$XPw- zCV@iH{Ql?Nuvpo`7RegB9jPn7-q1WU($HNL(n20HKIwluynS#vLU^R80y3-i$gi+I zoGi9pKR*)=M^abNV!}ILH=x;P1Sc;F7u9 zSY*vY@1RmIGi3z}($Xl$M`w-d!dYNaLGQd?mcffp?%FqoZ#?u4T_CmJ2e>c-{`K%v zcwHsYC)HGh!e~)bm<113SM0p}bZSGI1ptS&q6vvuK|9H8xO5 z`}Qab33j@+paeBkf9c1488aUJuIZ%)b4%aun7+h{F8`)O8kLlWGBop=ZA@P)F+%Wk zTWF}}0w;}Z)ERWF1^!qF9)ia?=R3I;O|P_3!b3Mki<`Wya;=z@_SC*K0|7_c0Dvv3 zCX+|~)`mmNP8*Q$w+hTE;Hz8E2{c55+Vc(s~T@Svn8B>2smZ1al@KJ*-Uq@abpd<0igotTFyu>Y=xnPa-gkQT=@1qlV<7GE}(_ zH}~kU1A6LLQF@#jm(M_JxOShabT@&u_2+bF!ho2{40I*P3$5Nhx?5bPZasc8K)Fr~ zUKDWpJ4Jb9Y-drwXx8qikSGf3)LS{M4RM;gf}$JIVJ$;-*NgkHPVA_tRc$=>vzZxA z4N%_suIjx}Z8tUi?bNHFE%K+1g3@oF?%)|VUcgp5EhCuLPLRkXE#3!h;xnx@M00{v z7JWBd2i{5i_Ce80j*{f!a4nUTo`Pl>9|&*oM7%DYa_e~Ju=hmK)OpZMIq0~tCNom8 zNUVQ${^uWiIWqa#-EZVrg$g&--h`bz+(@^s)4MSyMF$9!XyEim8MGOA1-Lt8IEf1P zt$c++Js1|qvii`+T%-n{`@HE@%}n(QQ3-rtpN4mvrPcd3Zg&Oqvgx`s z@CbTrHP^kgO*Lj7J-vcg6K>y0$x#739k>D*Ye3(`MA+Jbj(2MLoCHUdmnE3g4#%Vg z_g*1M)~q#+CrTkNsU?j?dq$2e(unBKQ-1LQjLW{e?e0Lq{idE7>{sY~M=9{E-lpe- zPms&*`19T6RXW8#FQ(3(-x-uiZ+Cal8VIaWgX}8TyMVpJ_oOl%FO3mrbJpItl4k5~ z&a;Q;gr~T)3*x2q5%>_g#d5}cd$}~Lu-eFvsz|?D4WqmfJairZ# ziL6V3g7RU4uJvRP6a1$U5g=3Do7K#d@%T_>R5*LzRBJU^`T3IC# zo4MllGGWPHKP0OBE#H^i-xQjGt77ODTHiUn)Lc&AmQwX}ZNT{M3M|xM1iK_sXxk2M zLIo#03yJ7Wln@;Kw>r)OW;T&JU~+$kI;vUWWv9&xOR=K*b1dvE_C@CsdZl+zg;8N9BIQ=T^I!NH!*mBSCV8v`004CWfB{if4U0@6XiKG0j@tP$ zlwF%yxR}$?%NdFBdT^YYFJ@z<5ieX%{xI5`>gZijWEj9?%#7vQ=m{C-gw{v|5tiQ7 z^mDbl#W>vt#H`)Wu-mC!(pebhDaL$>{)-o4h&5bD_4s;lJC4Wcfj7rkRjM_^wk{LA zKU=#JUTEUq>RDpUAi6HP&fe5e!~rpe=gu8>7pb}Zdd3duI(XMo&%PGSr*ZmCN}%Uc z%DJI!%4nEZD89}5hlXT81)O(itfA*1Ql|#9sQm+L*lp`A2ikn)qMx^vV}|MPuvR4x z?C{Gw@n|SLZaZP)=c5Dg7g#}nXA5$`-@EpW6*-)sLlxapuhWo}=4f$}g6HNY_1g|N z^|hoKBh8m5N++|{4lP5hqt>EE7u|!fVXhT%^$Jxe4AwdN2e*_%z})u9-_;}blvSgO za=K(F0FW#$z^Y*;Bmoyp)*6>)?GdqG$|qc{&=*bA$??|(l11g>ycn>l*b?JiKAZ|Q zjefcc>>P@vKe0k@wA-U@UFJKJhFBQAl#{H;gf7ZOqI$X`Tl@TMyWbFs-?=H^MS#E6 zf)#GM23T4^h0nLRnO8~{%Y>i649CdM-MtO)8k|c9{ zB9ZCbL>e7P^S2t#Au6ThcAgjI2(uLBY01c5eagLo=3SOw#y9qW+Jf76?X2XDM>cDl z36mw^8^Q*aqh!o!Ye-z`KUcqX5jg-|KX)Zn8{x&yUrB6jE}_$JtyBdTO2RB4hX+iz zGY?*WE$e6{{4>)^27lNBKuc|PjpcT1;KrvD*fW|=TE z8o4`~E1wSM3y{EK_iU?L$oG4X_o&2*Sbs@eu3MIeYgsjH&+qtDxS%oW0B4tuzp8(m zrhi-JijAzneW0KMS`;U#*Vw|Wl-Gk0loXn+;ENY3NZYf5;Xof0u`agksFI5y8w1t8EMugUpQOoBeEgdIp8oIk< zanw68X*4oz^Daa_db^{EVACg^Pq0~Uoy;^EiikXmvn+rVW+$415Oe!dkxlP+1EHlc)!0V=2C-X6dB}LNL@2@YTa8pNM7?kj*LI%_xXfQpD*^C zTaF$kW0|6z2)e2Y!!ciI-uAQM$&+IW%5cC9_j-06PtGe`uU05hOw_SmZBh$A=LXF< z@@p$8?&0Hf#3AtBd8_ulN;#G=h2e@k>M_+;5izgbx+l0oShBXmiwuMoKV`cD@cm14 zXyqVe)Y)}gmpNin_3DEeS+-+eiKj0KZMEpNL88RW%#*oaxi{Xp))dM|V&1L_Na{fG z$DoftsVfoEaf$;UCem5`@y@+>=0k?BDue~R1st2O@ogSmsbD&@>C z--ZXhW9&3KH{?tD0%k=C{Pn_+MJrwqacAzz?O*X1dvVHfk8hdeo$zK#b-FcH=x3`? z(MYsN{zF0J!1MlckPmU60Qz=$ub6Z91x3g1Fv{CHdQ)}4*YICHnoEg5No@T!4Xq0` zkxg$M7Q7aT@AUqnxK?=K!rGL=?@5b{c^g^lHelA2UnkJgkPuWn^i+Xnl6x(*XP$XQ$m=|939tznZqqEtBb2imPcfnUT;Nh}h?G zs&u|46KA2Jvh=ox(by@mC|0Nv5$e=A>2i5-oB{A_vDeT_M?#{6MT%N+avC9kTY*k% zCyGLB0kU)eaE6VCThP}sJ-czE!6G%_uS^=fvH3X#y&v2B3&9{(-XM} zh1@`F$j8>yvpco{d^CT>-T-tdX$d#-_>0QK2GU864R9@3R{=Mq`}pVm&p~v#w=3eZ z{auZ{Tgc}HwmTDa-VPVa-tj&MZZ`9g5VK}-;w>0<4=>SvC&lRZZo0SSCM=f1d9uz*m-LM+&?93-Cn<#{_$(p*3rZ04f{Jm5KSy zdpRIR(anmH$iHs=bpzoZW`VO>XP=@b`dU3>Og4c548+-#DBn z38bE=60q(|wh2#w5(vXV>E5&a7V;JjyoGE%AJw|CP?_D-l1M!OH&D-GgWH~DnjHS* zczrGCdkepQYmSkgP$7)`(_(?cm}N<>>pIPbzWcjYu&#j5H_nM6N#-e=9anA}pxpU= z$`|%aL-_Hc-NQdh`@F<^ayLxBOM!JlcM!uZ0zn5uc0Y{E%Z%JdL|Kjn6uj+EJ)HQu zrQ`6PzW*B2^jMn>8P8l)KhG`@1)anOcD7}UmkAkokyD$7K<5jRul@h=(nye-HatND z?vF3u+DXk1M}En~r)tF4taIwj=jQiUMf7>AjLA-gG3OW1^T*Lbl=5C@(;iMUr~JVH zup9{cO>T11NGZ~6e=m%#K|tS^@|AAH8`#@yE!j@`JKL%po}{BfPD5sZPuboxGUvJ~ z^**X^jD2enkhn4c22(y82zYlabpS`yh6!VPw*IHPU+UR9Ji0#|@)rkbO&kZ1b_%jQ z^fbgIc*&PFX<6%+hb|#Ixf>kaqtt_q25>u}TV(Q~7`$lB;5FBbYx(aCviilXg27WGVMo z$5w?sk6hud)X3={9}IO84^ZUkIqLytSm&tgdR6tCbSII#s6%Yj7w12hnPi}f25lbs zjWTd1{7PLt7Kij9cG~-ZsgJRg@UIAkuvrg3QRk^c4$#?K1;m0~*;;TeKDUPj^=;<- z$eUDv?m_CF(~qRlSf@V&5*+j^A~8C}^Yc0d;bNVnA&Ps{Mk)J9iK2Q;*N=u z^q|(M6DF`?CcCj$Lm{t+t~?daEm~w{ZDCdwzHqn$5*Nx1KQP{CMUQR|we#ol4iN9& zB?;NcRrt)RWXZ1tub?Ok(riu-B1F-z=cM)-X?MNIeW=Nyw3JMePWD<_^uMvWRq+!E zz>aMFsP!)!Z}|v59nt3r@(%Gr15YMpnA3@oOsfcl7aAkYId-!DfriG-=3Pc_vmi0GY1Q27O%P5z= z>#cSedGgb4p*ogNJ#^8)3$)?HMn#QxAHq@OHV3doTEeFzr@0PG)|qyGGan`|HPXoG zE&)1#JXHna4}>FKlg?NEr5^41Ufw#{c}1mvx*&3K{f% z20H(b(o?{ICIAo>al8O^dE)A(p@s4jsHN$AcD%&&q45LB*99q!HVwA&J>Tl$XQ5|=#v?+yu@!?z7p8!nJkgg zlEIU*&H5DOmRvJsx`;OW52bWAt^P6s2Hn1$zNWnH{mnfgqNjFl((wpv*Uyd$L6)eJ zvxD*xACj@vWnA()3{}K6{+`tgy6_9)t4}tadK%!;xjoO`BeN%4w+_$1LWO5+lB>j( zjk?`&Q{eWfevQp=jf5UQ3A(KA)N#AhiV&E5tgmNUC&1g1Miw+p5BhYv+=RAHuCE- z5WYsd0wpkV>F%LS1)ZzR(#~0Aj}O^$q{m1{dTM=Z+#7?dkHnS@0K*+;L5+`eAc-8I16|^$H2ZO4s;CRgVEati4P=7dbbG_ECJshbWZs4U|L zGUt;85UI`uKY>Ovj=+i}{heuDF%Z&5|10wajg+)DvY2W~_QQX+=My}Q|8SzM`Z#?N z@`Z&xgwM2**RfP?#++>D7Nqd)uX!GFK>>%lC=?kg72eaV%v0G$cvNh$(VW8^SPy$6 ze*Bsls{~st1|Ne!p@9z{$0T3SFeQyjz3sjG$%NtM{nCS$6nzaz(c-%!3tA15*!KC6 z-42(*5b8#pGFozYnQ9$T0IpJou+LOTYN9#n`#U(B7M{epAWziu?sQ4KzhDy&X-HTU zS08(HGJMof#ZSR936GIAa4BpA$F!$-CPn@s2iUrj9s#4;smiwa_6xO)`jh9{Q6z4&(Z^&jh-H zp!$-|6&lc-8Sel5kjd8g9+X9y8{I%e1xHLG)=|kP+Xn`l+m@i8{uVQh9fq3>zB+ek zW+nlRIQpG`X}Vi}vO0DMKyv7I?WvROg?R5gJJz-Vzc~UwB$67#Q%$OtJU={LlJEp_oy&P=PilJsQ zSadW8`HWcz!>RL7skmL6(EJ3vA+TX%b**RIYGjwNWb9+powLmC&)g8pQwHPMqIC^~ zPg7-jUNPSIy+$Q$!>@Z!JMh7HRb zSPjnVS4s*5CQ?!CjG9?hj%V!buVk>?7p^)~b|=Ci_6@shkuF93B47 z>*gWnd!FX+j?By0g7^{bza=+7$8-UCkyY|cBr-+Xii%5@dAuF&{iG_|OXmSB2WCe`fmY2%EHw1e`)T6T;79pa`W zY#AvhHu(n6Q@sbLEgVDV>yy2mz z$L)VFUo4rRv>5)qAZ#;Cd|R=9XPYtP9jt0Kq*JZ8S><59LCF&%!4CyEyCLZLByQ5@uh3rXMD|}TF zqY+Vj<_=4cMplOie$bx-hbx%{6?su_tQou-dQXpMAjs$eD;-^p9GEhH4~B}KuGX-b z;_M^}E%*>zrT=BR5TkMi!D^{jz8>8JPu4geTXqcZ6*$DCSWt#N+!G~yB!tOV`Z6I| z8?EK2JwGPAyJ_b(Jc6y$c1~}$M5uo)jE>E=CQ0Yo%5)i<zm@_&5s;Fis`&Y{?<)1|{#J2Cv4KzU6wv&i zr4epEe2p5Y1lrw7t3`SD`i?cipzV`p$>3VeC+%$Pj^DReLTv0$z90J7O*o3V9Y)3k zaE6S!=}1i4tj&c{w{rwr2_IDIYkS9%$=y_Fl3b>tqrR|&o%p}>G>yENN8ytKhIGoe z6;D#AsNT%^w^epg_)TvlnOj{@qoHoS<(-#X5GaY>ZRuf4)Px41vc+9&VKN!KMAdv4 z)`S?d<7q6Bq|FpV1JIA)W7S}M(F7-c6*eSiZKrvWvmQvoz9{xJ3h(XwFlMbxGT2LQ%%{JQ#y^~*Tx)kwZC)k z2M>F5|67wJBncu;zd2$%qIH6FxFYwbF+h-FW6=hu)NeTqI{mn3f!Qf~2)!0wzDz{< zH3|MJ`&gG^;MY_|-7B$o8CaT7WVMeNKAnx2biUS>!7H=Q)TyvpIxSp$)Cv;F-fW`# zfBX;wTW$^6wH-7sK2-$(yxTbK__Bu|7KGYcr}EKS6EB>PC!`GpGpA~itqWROd+fZNZ>@!1 z_r4nGtJ)=|wrAYWjwN)8_SN^!(s_ei%NRjhd6+Vp~&|24RB%2Sls6BOcQ%mUg+Ms&`%Tf67XE7xNlsyX4_ zg)A3;Fw*8$hvhOzFSF0PI^-4A>Pj*iixWvjF+yRF?5nu)D#KOZh5Er&;b z&&y=nJ0BnSe${mq2Ia`?IA3pzHMQ!*C2>P4jtvK5LrA?jsT&mAVBKtL7Xz(bmmJ@zm-N|KlZ;n=-(@IScuKx{OONh$PgDOGuU! zg}8*h3pxxD)U7{*l)qxh)i#ql2M2rR$<+O-23%OrxF+Ct z{;9RoJ4`J*E9Kke0RyCajNtp;z(=#GY( zoS&mKbk;aMI`Q6*=(rRg++*6ewI@wW|M*vwMITNdY_T^6+!U zg?$t*k-C?Ju~!Z4V9}94Xtz60ZYp6P~eO4tUC_T%n z|Mq0pXEx%brn1A3{hu=+kShU|G&t5D zP%LtOz`gbd%97rp4Y%()a?LA`xXzW&JQDa5htv@Bi4}x&$ z$b~X<>PbLjj_78Bg1SKeuxtTuWyhmvR1eH2V)r>IOzIyu{_%D`vJZ7GXho>(jM@|; zwV#@2nn#$i8m$YNQbKDzWxtuB!_IJf=1R-MFKa4d5B|PI|NQyWZzM0st5*Z&F3rT` z?+=vio|?Q6=sS3R>5Ac%JFwVAp_j3-vG=sqF9oLV1(dquHZ48W+FF0y%YLd{qKs%G z;1;$&bQ(60a!V&hsdHG%G7ryrxhSlP;y;$gR_&+aDiPrEFj210`Q;U=PDnr^7agGM zt3<^9z~;#1q2sqv=ZAC0!Unqd{e7*Bu!nOJ)0UDPOWIie+DJJbTPvIJUh4&Gezqf>e36`k)M%J z1#eI>LqA5rQ4)2J+`bU|-sGbhLBedC zch0*L6>~z>yjGJXrt6{FYhbe<&(rCC-!7|}@VoSYWL{cRtl~2$9-vmAl)}rss7N|~ zyuhti8`$SblU-r3JXYbbC)p$VM9W$VJesVEyLPOkRWZ;u1U_5ty^)+2w^c7&6f{@T zN@dq;BrlIN5SCv*;)|ZmdBKYQmWBrSUZGLuT|x>Vwts71;#!p71xkh2OHB6h!Lm)A zIu^dKR21zzIv7}TIx|iYt$KoR7te96#3|v#RZmk`TuN+(6W;YwfKlbDox@XY;w7{5 z=G5zQ*uouM?b^xnoj5cJRD1R@% zV^$t-phsF(M066|)7~h?KWzg8PPu9Hx37n8j(dmqwu+P#Y_|><9vU!c3pp^&5B~im z2751R&3xWmfE#?q=D#NrvSo>jVV{!L`cazaFGWnI_gZopZ8t6J=x2?pwh4TI2a$~H zK%}ox=YKR<6dH)>YfM2>zCDr@W<1z@Vx->zd%pMF=;QIS z*sXl)FSsJN=WWa6mrCT?dmZta80Qw>9d3a4RXPBmGsfX(-q~SeSwhh^pMn3N)jjjUBpJ|kQTvLKdOBs*^n7UbDCIcn(|OmIobl25Y^Tl6Sa;72#4@6B zqpH(a$=RccGp_;7EsC|H2JLZdn79?$+ zm#Z!6?G-?>D@|Ufm()9AW5a?En!t`8PLHBSw2Ye`|c;;cAdW*8;ES*e}9!(Cyb4CVZ6 zPi>Z>-F!YGsp&Tgmzsa-1+8{!Fc~J4v##QL0EI>oht zl3sS3??Ww)-gD|L?`O=1kr+|SF0 zH(#N~LJ`dfIZa^7eJ%StlaT0gq~EJ{(hbYce2rx28eo!qxywO_CN}}jI};{@%eGC5 z``=Wl#C0uQwS_@Og)@q(iV54}^m|bg=ChDfFbL^>4>ml$xJ3@kHv0Z8;~+2HC=?l= z-ka8v-dF-U+)5GwTYjX)!eW#w_F39_KZ*xW3`tav&4q}{q?J*t2PC=}6&mve2>DPd zVe>x3e`E@8uY?R+xq)kqj~XE6KM^IfR_K0#s1fbLXF7;5inol`OcUjK(CBeOg~N+E zu~rVZjMBMCS8AHZYFvtsU=U0n4Z#CcOz@M(orns(Sr_|gGi6+>*sVXyN zl8wn-Qt`^v=Br5Okcx~nI=)dZ6;L;Q#(}LoC+6O7GQu89sVI%+KbQ2J(qy>$3 zH(kIhBwP>Q|7Lz&ypuhxRUn7U+AEb(v{_gOs>^NtZJH586PFfETo#OI{}8Ed9AVf# zGQV@nBc-4(X#iVXY-CX**jPz$+Y?c;r>;K6yqyuA3iE#`_N_|_&|F8e;A)PVmDaq- zRT&k+W0L&|rLA-8oybwA1YBk2#@6glS>qej-#Vl9_)4a#vzAuyD;F{wAcTX4&}L3L z5O?CP5Ytico}5DGs_HNkFmtz(W6$W!%0#^4^W#w&l&T2UgLxogV$nW2S{Y{rW2mvL z5ZhSQ#?W?6D*^&6;2LEUs=RfNK~o*)J$zE(7T5`_wUT#b0%IAie_HO%$23Xax;=BL z4+OA#mYOv4dFoqLSS$^fEUwaUseFm@b#*#7$}0-r9oyEI9$0Fp)jGi(=bhTgb-C$D4e2Hq@(JNK^|P00I`ot2(k zyV@@Qg=Jljt?HpB_bMW3Lx5^BsbR!oE@!VV_nHa>m|esI9@6C&Y`fM5vRpoG?KAgP zsg$J6UGWI6X&0`Bx6<49%8vD1$sKG2gnjX9MepjbD(#{(dVpCnZ75cbulTC98u=Q& zllnK*KzTWTu<+T?L5Pgnrygs=Gcx&ga>U=K>**x3-lim<=81k*nZ?r zpb+glrizo65|Z|$pJI3HP(Zui2z%xFvl`{ZGXV#VOEKsR=gCX!*8Mzl`Pj>R>&sq= zNWrfb0f!t?oLS30uhQ(xsqB+`bdwOuSmvJcZgYl}*K-x@D`;B+#|1keo%2S%H|h9-jDj6i4z$-baEiy$gc zC-C=BdHebAzzvpS{8Cz}{Hrr`rx<`o=wd{w^JtC(Pu38o`#G^{bDDbC+#bIgRJWCM zqabvCSEs%1Ol$sk;KL22&2wt>zbaJYk7b-UORjg*JT3Sl97_g>`H!${Z!Q@}A9%E% zCV2&@Bk=9~0>QF5BRyaCOwrK?BrI{}(_inbVl%0L>YNK?L7CLESkFec`C8jWkF0~# zN~G#)Ws)idcccE?texdVQ%eJO%dx_+ufi?bk4pHdS7U51qBHcH8eeYMhf1-?`$qo4(ITAHRl#db1KS~YGp~gPxtQ7ruZmK zOPRbA?Vn882l^i?j%}=kZ;$`<6MXvIAMt#rcy6@_hZ~^=T1}I|-T-O9YXYs%`|IUr z<)9pFW`5f)(tl(<&K~Q$WZ-|~XOc_LF!dSz;^XBi#f6b}A^$4V+OjOIA{y@T(|YD; ziO8#@xZYYN*IvtlPvjA$-4xY8MHWxu)>iI~&+c{0?b02D@QhEK6`%875!e8l?3VyB z3xC}yRxaN%DJ^@K@LaBe zE{aj4^lVX^HA4 z9K~Ym*How`e!sicV=DYv1t`T7HGHHDo)IAr@=YL-$oxZ0RO))))X_^7HufF6iK{c; z3+%@2I}KQic?P3{A=)RhvL87{c?FAC#h{FTV+bW%9YbYO7b&=5_A1GgY8~jVi zEVDrNo#F9pQO~#Qj*gNYtPPGQB;Y|)qR_rcXqRBFIU+bNZVh#DD z@%tbycLh>ZPup<|+mAcP3R2=Y1Z|O=f+@-|twCxSb&k z3J@*v!Y#+sX&Y&$tx}@~t(w#uDNB(*NL-tC9|JAEm^mCHB{hhqdxO>>%gN$paO&6L z+Ff*M!V8n%!B4v0mplU|X)S-9UT!g53eI>@IsRkcXr73=6;~*;c!QUtU}rS9Jf+h` zf7-f%l1WE?v#JT4rS#rCp8}5fw3b_BK|cxRD$&0lqS@p#ql%^2neB-^sbzc@&-AUv z@<%PlM)=YM6XPkjSiGuOx|Zj61{c&}HZ(0s;_~JVd1X6-^qx+roKj^;Ds0``c+motd)E&y%Z)ke@LRN;d=nbu+r85D5 zmeSlJX?yXk{iEQcZTrL6vuw5>m>FC@CZ*f=d+>MXrRGUw%W79(?Lj-e&x;-ChejsG zuHN#~)drxRPfP8KPo$(-0O#hn4BX|-c-zF+BCrOKC~Nc)8) zvZ>D^QhvPc3_U0X-;fGTuzgH$V9XkNNVsJ7qsZRO`>03gSN8Ie-*y}2DjSEJ=XXFu z^T58J{pR0*l=NN?lZG)Xd-8QKR(RNVWbzF?U(X+82lL^3EKMf7b#{AxHtjn(4LH?O zoy>I&gfKBu&Rp+0O!kPuOBG|%xmM;mjdFuEiUn=$^Nolw^U$(JX*)r|3QM1DYD-9{ z-bb!^1t`(k;j4)Le8i^_Cz6Yx6zE_^Dcde$MwdQSj`3q|I2BvgrY0Z#TPUesp+5Y) zv$esYDSY6KsY#@eaYmSjT{P~FDp@Saq{xuip!OrpKwRo<=LhAN=tM=Oj;T0tg*$q< zqpRdVU^eH?U+G4)iHc| zjbsm+0-x(?G<|#~dOH8LMQ4+VxPVegIDea>1jqB8oI>R6W-MYRX0jUP+7|aq@%T2i zPrW3QDQIKiH)VfSU0tpyufro@dyyy!J>7^rXDzuM2d+ceO!dzjBhiP~w-Zxf@rvWc zT7X$vO!vB7w*Dop(4b=a35Zmh%ZKR~sDUtzL|wJc7E?Du1J@Z(~}7N+XlAM zT%mR7mejXwwB647n@=`hRYtr6M@mjq}&jxau?%JTbnnul(X~vPkiM=)}y*@aQHK0Ty2H5 zquW;h4NG%>rv5L(^T!$uVOqA-GVVcAm9_@T9Bnn7GH}Q@{nu7MhTJdpq$OCdMj{~> zlG2FiDWHsvprJW`{CIYMnY>T-X)RPFKmGe7?NVIJu$lK!bBoAc=yMmWF4cXG72g;y ztF52C4i{2F;@rvasz75SB0=NY`BRn!Z_-pgY*MafvALy#^FP)kcU7r=VoAx-^EI%kWZZn+jkS3Pwxj%>*Lnr3>@!_t?6Wf0tO1Lh86|BKs+<TNEEw)LBm9@xJK)%TQD65%v zDyp2@xKcstUhHMP5W?pfJC zcgOEB2q(oYcv;@To9=C|*S#0(THi{8yf(*$0up5{(72p`Br?Xx zF_q+|KZ88F?EkGnD=;7Zo|Q30?k<3z{GYK5H<{*Y;oi?iI^R%Hqfb^`1i>~X`2C)F zwUOc8&(SC5Bifrbe0IXZw>ElMGN|QLx?emOp}td}bjr+MuA}x5Q$H-tgOI^*$SEl- z)|@`YubzL_Q40GA&l;Gt@i57_CtA-{!{9Zg+z^VPKm0Uf%JeF4rqr?Wf-8~vhn5Bm zq~^$PI|<2$jZPg>W)?gO=JI|2O{0BAUrLx~mBSeIStp6{_9$+)iVmMF>7zR5qp3M< z@U=)8*xMqFnn(*XD!|wCHZ4e$8Eg*;nmNy%fg#jxgD`Z_qrrkd!-EdkS$1{GN~Dsk zVQqmyEe8ftsgEo9-J?p&wAUWqy3b7eo`DwZ|C*IG4LIO@)KsltKi`2F-}oUi_*ma& z5nq|}ihDli>xo{-RC_1x#>rViTj39Ai(#gXxRyQl{h@c4Vw~76qHw*asca4H_+p-o ztIr6PI_r6`?B3}wow`6@bzln_&o%=eE@Z)|HbCAZ`a50%Er;?hBF?uLTJS9GVJ~@0 zHT7xykg|dtMTVbh{^jR;dvWuG2z^f-2cCNk`Tr5)bd$CW0Kjwo(#1pi4lSdhB`MEn z6VMJHPdfumE!Sz;9_{b0`hOlTV*m3^LQ;-#`jcad;Z=s= zb>PzLGIkX7va}UVun)fod2?*YyJbGinB9GNDcL)yG@?wZ{^v{6vdRYliFD@vFT~FepMTyVgJLvo>M%&E@NBRvV{^!hXHDd!$vx)o$aJxMdbds%#`PU%T!-VaBEU%`kC?EqlGm?2A@#E=-G zRr8RRn1V#rJf((2g~+$P-@otY_xtRB`?~giuD#c@)_U&eUiW%#Yz$0)dd?c@DSiZ1 zERYMofPFl{^XU}mVbRGb(JSNY!%#xxS9AuS3xHV-hW%vg6kOOXvTWgs%!E8PjU{Vn z**3{6wp~Zk9aIiz@w??rP|ZuNb%>5QC~%s9tTFywQT&WCU;nFrI+gbbXhpjv>+JB2 zg7=&q{oH`9%Uz&ef8Or4*$I)tmAaY1$;Ev^flKBN?}BcWNL=%8DNBgGpE#HqsHS1g z_Hgm!qpqe~m5>Qn%^vJTRgJ_w9jf`~Qs)bAh*oBFv_&7l7rTrQv(&wmbgU;->dMc` z@l%CfU($e^JbZQXG9Sk+CUrAZy3!5l)J6FP|3PHscFC?I=bj}B{AXDn(k_W*Xwq5U zoaL7b(}SgyjZW}|kA|;@OZIr}AAd}}Diu2i9T@-co(<%eS#UQwK6LU4x;^7RSGdY> z5t((z>;{0G`;|>d(Oau=;(wGiz1`rAY=|#wQdy^aaI$Fbi>{=^;-}jBM>Ia!fj4)R z|M&SUdtw|#Lz?;Pqs{T;b!~=x#cP#XYl~fe=#*EIVjMQSlZka@=dDAbtyN z6jhfM=9@~F*_b!uSE&>}^}7KeXn$oJvVD)*Y!bP^Er;|;x)y4NZqwcZGlFfZj<)~# zJKZau8Va%0!(8yRq17}fCLg_A<8wIPMrghjfQp?0eT1AWnAZm5zf+NjgLm}$WJ{%j zDqQv%!%~xkeVK)l`&G+MxlK4(!N;a=^VHb=jgyQZm&sAz8~UEDF(ED8C2bF2-xNS; zyyk&>)9_#H!Omnakg^33UGpi9g?(6qJ~{i`^#J)OdTIa>8CaN4=Xx+Jo}gxNIHt|+ zUvaJQal_-W)CD1`it5tZ*r==)XmKr3-n!1Iz71!1#@`A+Cg*Q2qkWQ1VIhs_w>|D< zdOez+)(@k<0QfD4WVGTJPp#8Ofr9fgq~VC5trLT)R}(LdfULv0&%iB4O>QKjxMM2k1Ji-1VeJ+^~*1zj;pSGkUsMdHEije-L06 zf&QnB)}sx(%{`Kp(rl1&dM8&U6))?1%P{I6q(Nst=6=KagR$6k=arAWC5OH$G;6XA z(4u)_&~y^R?d%?D}nR5|(C??2F#LPvC zPC8d<508GK(a{S~E`N^xgLB-8-Pb2!Mk;^E6vn7F+|0}Wxwr^pU6YZ2X@BL_JG%$F zg5uVDF3zdN<5ux(fu0jPHi5Z5-FEq_)2;|kJL7JUKjsSX3ku53)VyPzuZZzy(X2_fE3}I z&I2kS4KH9}yodhoii%&-r@pa?oYl-LH23ag@Csc{6b*&6?q1*(d$^%1p>0LeK@1Vw zY&T>Yl@n=uEAj&$e|4!<-1tr4gc<;Cw;nmQ?8KW@uYwA5wo>IhO>e7=MRtpK&j4M95K|9sQvCFrI=ggqizAWA1w_M z8!0}@b#{ZdN)|yBbM%{RRR0ms_&lZTCy`*su-VVJEyMrLkRtp(tEAlVkSk@{O>FFu zm*^4klP{9|7z8YD$Jk=V{G$8)lHO*L=g&Vet;&{B57E?(^$ z)z0)c=JM#L-X22!Ofoui-asPSDv>eE<#F+8c)c%w8^7JCKN)P1CJFZ6d>$+il>-2r z<}*$286(W4gB~WaPo;^^Mp8aHdt8%v9R6Bqqz{f5*kC84c?z;sQyqFXLBbC9wVIeC zo21h0Vx2?V@ReC(qtKRWD5h3zY-s};IkyjfwA*gaQ$iQ*5Igz8ufLQ9G}@@5hxuXe zJ@BtHBxS`keL|k`WCXC>5RpTcM~7rb%Y~7kvgJ48udiPXRe3e7_&}98tUeZAyw)c! zDe`J^os>sK`4XB74b-5_>^!y(^EhC3GBCuylI8JF+L@Dj6NipVrs?GD2=!Igio+td zM4^J9d-wcnHQ8~`eAuFzSzoYsVq)l*YzBgC!fN3+%GY&l*r_`0e*vJW97@Hrvn^Wo z0F-%#D+Ov#2~^9&6)3HI&1k(nJkLSMVs2mEW;tsQ%AbBMBTJ#rEroK`$?C^eA}4Tp zD)grF$Z%+7p2a$O^wb|6roa^YebUF!!)k1PIY;;~a=8?_^Q=2})0AcKqA#Q%@rG%q>RUOx5ms>$fa`D7RgovoyyG1-=Ql2XV zGPQZ}&SEL$>5{2NM}2NjubT+}B*oS-*#FyyGh-o-S77O}b|mWEBgP)7lx2SH>kmF} zsx2>KBJ#{qY!8q0-+jg2n~%^v-Z%8Jj5UfsuVt(7sRHN(4|3ELgt*Dx2d+%MbL^&W z0dv^>w5^yUx?Fk5hDFed@T0roSA?-K2n1gyF~lm~sx4x=ZSM@%q>7o=_@t43f?8;; zTIkZW`v!xpnd8J!WONmZ4hCJHaj7_u+SWLPlW&TsQ*ojpCSmly*Ve%r$R5-UIYGT| zszUEHT(L%%8%f5L$!@&nHz)r9(woQaKO6ZdwJUh*fkr}kJG zzm=`jtl1yGv{;L3__oN&s!R5juU}bq3hXp~a*gKP&O6$a_crfv47KW&%VlVbd15!2 z{X0U0=er&)g@gZL;3jj7U(Evd1$zn91k|y7 zK!c|8f}-tr7M+X(5aVn)?qDB!Okz&*=4gF&&{^y2qi$OIBExpL!+^U6N$1BUMdY_^ zE;)r-d!<%7w zn6?iiw|$>%wmPp-T;NS zt^sK3mP)0Y&><-}OHWK?U9Y{RDQTDzX?LMhsIb)PDft}857>Wo_@YU04&O5p4(rv- zzgQg74LRhi)De<>){(JP;)w6f9KSTX!Pv+_}@ZNJ2T+iq@-=Rrt&gnGQ z-483Lyswuv3NXHVK{l}avzp~Y)sca<*fQcVBkN|H{YY`;cT}}ZMsV>nUAR55q$)8q zm4bU1*8!}vK|`ng9Zte^^s9*@<{X?6?R|kl<6lh1`%<5nE?Z}8YU#yD9eay-kc_?L zALBN~!dq5n3e7q{)IBO5>662^@27Ts-v32t$5uC9x{)ZDvX_96NBT>1ppKnGpBHC& ztyvMZnf;byjo!q!^>3^AlGYG;s^aM%II(PQBihk8<-M0qUFU2RFZ?Om!5<*WY~z^gy&2IKw}D5J z=`qyM`L^Yx7>_=~@#hlagFcNKa~Flmwj*)VHZMPpNfO|DSQ@Da3R&UIi#T|0Bxp|_ zuE*qFZk{tjjM*hE6h&W92KjNFSkSOjEmYC;+p#)<%Azr)VR#g(c09Q#u&F@Lk{2q} z)zzlvutDeh?y=Z@%c}j|qkX^1Bsf=oSvsj9n_npD2!w5q|g*&)`&A=S} z?DitvRmnvkhdc~p4ZtZC->||^p^vVGOn>lHAW!_Qzfbu~%V)buk%iYi4Cx)OW-+&qL6Xtv5ClXlm5C5fsQa=s~qF0y_Ep# zQLJhj73_Q6rY}8RC#q8yNWhcCfP|_ob>u9ii4gh_4s5}UvUKWU^*G6W ziIwx5Q6GWxK{{O4W6wV9%4BC--~to(vh$v}+CJcdYVNn!YWo;pHjibC`uN6BvYtFZ z%pN{$D^$NJgQPew66E`LZ13;^BoZfZ5?4p=NZcUnX#P=GJF|T$9Cp!C^hQU_a{JU~ zBHQbLv_cg*%dz`LrGwsHPX1x6)%$~Gj8p!x0P>xYu`Vhrs6NahG(=Ee6i<_y@=&X> zXB{j4iqb;C<;(UgriZl`yUvk$@^hCrKol_+;d! zy1L(G6sZQ%SJ=5x^Tq2-GtANQ2TaxU$-aHS~YNv132Fn)m$q_ zp%v3TrPB?`w{wH|`P0|xdF@90oCW=ZYBZ^%5F^hnhB3S*P-hH(klG5fn%rZrIT3-D z^P)|ccXjahoG6pK_v7IQ{f!D(N%IWFj?F?ZUvv1Qbzhbw9+B7Wf4;-|#s2vyJ^-@W zqR2Wn2cn2pIvXR_J%dFXYs~>M@{Mn4EQEpmOBN6w&6TD`~Hk^J)Tu=~$ zz2;=MxIXeSqnD<296|3io7_!u-nik(zpJQjAjalz{5>U{T7T2}F}3AxXuFHGgC1!qCJXKKymbOmncM_fmA) zE@=EEz`(%@jg-U_!%ANj^wW`GJ;-b1?5KONCx-rbi>#{W+{yc%r(46L6j~{J%f}D) zqcWI$dzT{?IP&;sq>*w5%NMw^HAxVIyvjny=$E`@ z?8`4uE9jslo{6{XEao~*jw)`7(b=X2R2z&{8eK0h7QAX`onE;1^cpW|y$o$lv)39J zkEWo^>SOA=nqSDH^qq@IgJbpK(7M&e+bP=X-ov3G*T=KPt<6k@U^2zGOsYq_2FQwj zPrBN1tzFiT)J)m=PPz8(pV{uaq8)xfY-@(^OIM2ad}R4o#;g zsVEz7P43FPuBO&pjA_!Qq-Tn|QrKY)6}d`77=?%Ba|Hg($f(}P89u06=bF2B2gvu3 z!1T%@3;o&o9YTyfVl|v!Y$(%*l(~^2$n108A~c&hG7~+=OzS6O<`95?#A+g43z<2 zk;sl7gam~;eo&Nq5hL+Q?542r8yBNH>hC2Vls(Z?5}i~O(>%I3TU|v6QOK!j?lgJS zezZSyj(%8xc6PLuU@RKfq8FU>e0~*#(+XCQQ;V@SqEQk6N!-D`IY(MwTI6NVDG_Eo z5oVxrB13*I&lfv`wyxo)@Nh*=P6lq(jh{WazQH?mBZ)JKi0#yG#q)PYU^Zx;oD1DIt diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 714b5b4a6aed1318fc9cc2f558041945c4e27905..1967ef768f4228f7ca9044bf7504198061a1f08c 100644 GIT binary patch delta 403 zcmV;E0c`%h0<8m(B!6^CL_t(|oQ;&vN&_(v#=ps~h+XPIWh<@V$>Kq1Pl|7$PvFTn z^6bGE@TRwV^df?PP6Z1srHBX7R&b@+M3UHT60_nSmP|6gotbZE62p54V1U#Mp?8c4 z{g#ez>{yWoa`$tEN!r0WjUCd%yhmyW%WVb+$%u4hH&=^~|9^G?uPLxcwgj%V*d-3aMp4tVxTel01_Uhb&VQQuT z%+DU9qaDk92so*JKsKMC4axgCQ38$@++Nc%_sg?V8 z+5k0I*nWcu`G55&4Ew!pu+3#mh3NEk_7GQnT2D|}kiEB1WOBxZ(nL;kj6QyN0BcKB zU36eFf=RzUMfWv6Ziz;<*kRkN{Pf-l%S?O1pB21+@snF6^-N3N>En~nNoO#A^BFJw xLxGVjc;R0cPMxgmGBCXHfOQR#WxAdA4XwP3xl)2D!VCZa002ovPDHLkV1mG<#6182 delta 291 zcmV+;0o?wr1HJ-~B!2}-L_t(|oXyd{O2beT2Jr7BaY_6GMVqQ94w6mUMHgQ}pTMmz zxpChqUD6D^-au)qCyYneIK@-{Cu44^x!)Kd~nup!^GV z8X*a`pRBRF7covjy8Dry-dbR%QAOw@P60?5NWIF=voYRT`G3Bsv%Re5I6uB}54Osy zo?KhRMy5rpE45%OTX)FR1_SITADF#l(wm7`*5=9s`>7?~thl?Yb{vW!4NAH!ohZ&h`1PaZx2d>KMgFGRP0x5(Z_1 pXZOf*GYnv$KOFqfZ`iD+!Xw3KW7>ly67>K8002ovPDHLkV1iVrjh_Gj diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index e4924417ba7cfb79b11632ed8325b2a3d09aa97d..fd15ff97b287ebb58ea20ed1b8c8d56aad213c72 100644 GIT binary patch delta 883 zcmV-(1C0E@1c?WbBYy)>NklYPzx5(QUygp6!aiU(Ti2^ z_G&<>cNXp^dNZj;MwBEgVhrJ0X4=S2iu~yQk#frtCglZJ0m;WNp_Rn z*_kvSOW4i4dEd-?GvB-+pa}#Z01UtupB%swFScj`(S!h0G=FJ;q?0_&d%9o57qL*lxOdRwyVhv+mj60j?A&wXYYs-{11T)TxK{O@4i5Nvv zH_uu_RXAjgVUH3Atn2M#4d6=d(7dC>Myx$qf1t~H!Eqb&9U{({&DFlJA03O{pVIA6 zjc`k1Teb9KfqyoFg=T%IAf#bp{r$S7gPh+>9~NtW*q07^PiLY0FTJlP;wlNvmN9Pc zvQF=YF!9g;dtc%@#KHb{{FG}*W}N}SQwnjaK>%Sw{FSLE>zT2$?(KY1EB5y|*9X^! z$F+P}K>RtU3Hk;)33QOeQRWDAIa+?i5+jYrivoQ9|7d~(0ZVryxz}Fs`9HXC zYrL!JihK3BF0mctSaX!$SCq}bzbWOpn0gRBYn{Au!;KmvIciT)njiK42ccn%w5m9 za?I=85PvCqXu!UFn2sh&X*`&DsCltWNAqOlxLWf^X+c##I#gbh2+zzDjG?vw^m0^ zAOXr_k%nA=hMw2_S?R;oIFDWFONhI#Gh94WIzaI-`KwpN{Rd@dD^XJ<9wz_*002ov JPDHLkV1gX%xQ74$ delta 552 zcmV+@0@wYC2fzf7BYy%1NklKi9 zL(r4A_Nb75p(n*lLA~`tdM1QMB|scFu8kG>uG^Au-4lwK7IoiVWt6>l>(H2u>0{9VBciWoQRa0 zB`x*(Xjd-u&UqLD4E*KuHPi~&E)`hf$ucHoXQjd-SAfmY1>8%Mwwonqar4L=3A+Mg z{70o}iMUfSP=7Ui$|o{%F@8=A$8rCsY9I9t{=*Vc&9ADB$x&Y8pqsi0IH6QUaOxijh?SI1bX&WA9#mwjY5Tl0qd=UiV zmxDs4ua(@Z<1aoq2?HTTrvt8kFV1MhirFjMu#n)R7i4_&f}27Wu#6fk^ia-iVIos9 zKpL*l?k4_b-D&v!uI#w2PX^&ITRL&wFBjr+e#c?hIt*T}kl78=SXc5YfO~2DnzG+b q?LWrt6d`UabaxDPusqmRu5DhTlqb~lJ@P%vmzjG}8ubfc@g^V!{1(yyno zJu}rkRb92azYetJbL(4r1mtRN4kn8+qPtviw<(BYKeRt>15utc0s_S2=S@m2Sj-l zb4fF_iUPCE27gU76)(?JvPpaR?5as*p(FiiSN(eiX@-n5+Uy0A0OYIPzlt=)+WR8o zG(zc8M(kTfy5hhSlcR@#5%J^SR=sop4ghQq7mtwEb2?+eUOP)_e($AEZ9CdlP#Prd z7taTVKuR9?)J|bMS4n=lz*9k&L07f@U^?c<)>$$%=njg zig(>cdQzY%(vfZ0^Ikkfrc0xC%wCxnj9QUSoC)bOl%#7eA|C;uL{ZKt5LLMmf}I&D zN$1A;NdfQwN7A{0J|Fby#|S`l?N{CU^;t4qimv`rWUx*@wj=3Eaob=ooCJ^PllGoF zL1wB2Nq;BNx-V%P+wh2)y%NLvL+DRLFJnzQnZN{)#R9h>-HT%9ppTHpj}0i&wjmu{ zSpySohwtBjnqPc8NF;3s@xxzc^?&}yazx=n9CLGA1%>Ucp|aB1yk;%L!3%F zc=x)xd~wCd?#B%S%vAynkehEg)Mw<@JoXAKj(_13nE2-)_S1HrejiW*=5mVc4MdWA z{J#*pJV)t2IeO4vY=j?t%~#ilexmhR_UeBB{FzYn{is);^)8-+APSdO#qV1V824>D99W&( zfPdfK3@$DEm!4u5&H@)eiT8RPI$^l9$_?|XOqAl4q2q)}{InS?uJ}t0Qj7OjL@RK~ zSnh!#>C~2jSr2b6h&$Wihu;kG4&Pp8i;cMB%L>NcvnS~3nc%H!MM$F=Bk5pxvrH4@ ziAUg}vZCkPo4pHHi2!k5VRrQR!*Rm`Vt6g2GPIqYFmERh;%0Ze3m~t!b&VBh zzT6TrsFra^Va~oHGn}HIZLvmt;F1`!XHvfcy?ZiiDXKo1Sf2Fy)++iBG80mnsQ@dX Q_5c6?07*qoM6N<$f^V^d%>V!Z delta 721 zcmV;?0xtcm3g`upBYy&~Nkli;G*Fa)<46GOG}4rQj<`GaVYHS@9A@n;g)u33*i$e7APi;F1$Y#y2qr7+&sVrVDF z_n)jCM{HMB~_+XM> z?KdX!^iIt+h`OCF>tK->%a{!9r>lR<{9KZcq@ij+MSoXir3{)js>VcKn;H}&Y197m z3dOTYK9Ye=!1&w`O z(5~&Q(|<}yAAJHAL21)v)}c;Jb;4IHpvY&1EA9qz)OPI_K{v+7Aw(AL8@>|{b*KW#l)cD`^Mf0ZL(Pba_7=X8|^ zuVs9tplPja-kKMq88MV)RrY|6sD11MmcpBYy%nNklmQBk@`aiPM))kBmXuXH1rWW4Z+_g{;c=OF zX3l56XJ+o1%OPM4fB>L*#x9S?c*rd+e{2o&&@C=WMcG8BNq@tL(+Rv>=8;)S*MSA$ z1ujeHRR0MDLu3Fn4>w$rK7&wF8^Xq637TXJVz?F7l+WG~v!6;-M*!lK%S)hia#XGE zW$md^wX)|ODl(eqoOK&}IHuP2v-YD}h--|8SAfl2i{F3k^%7t+*W-7)?wIs=@NCCC zMyIdD%ipZoD1Uu$)yvKFVxSjr=Lm%LJ}JcQAE7WP(<|R>IlKzx>v|}RRNJ>?iB*F2y_>;mlnc60N(ZC_9HV(;?o9uB7kN1Y-yWle)5X=78+Do-#a$ZGt5&T m^iXF0k~9^)P^94WPy7!)+||dpgTd1P0000%cw2#|LPm+o6C}E^gCx4HO*zR zll^z-F_djD_wV2%>X^$MrU5sXIYN^P<}&-K{M~vNU6hf5*;n=NrrYG3%VaP2clQGf z+nAV~#`##{Uv37Gq5)&(r0|Nj4(aetm7b0OgG<~s=XMtt5U z&D?)G?<3ed?orKLG5~SrvM?cH51CDoxhzbqea?S+PJ$Ay93N|m`LE)=48I8&O@z6B z{{87Y#ae34z|YQFYW}4P%@RbBs2_r(Y-%ylhN5qQ941CDUAn-~WEs9AdJPW<+)M|Ce8X*Bm0>y&&-Y o&%eD738*2*Tynvvxex#V&?SuBN>)w=00000NkvXXt^-0~f;^^XS~#-`ER)~>ef(hJdvYE! zAV-(t)Vqo^mqQnK72!Z^A$eD29{-GYsD>tn1rmu>@~ zg8^td2pzEj(0|UnB%5wMZ`h+ys+QIv^N9LTDzkeFBd*i()`MEXkgC)iL#ve7J zYn9Di$hJD%S_h4S7ng8*16B*>T_fmUGl__9C8jnxL^FnTC31^&SQ^E-Xo)VqN9pC&Y|S zC;?NGx__8a&^4&3N<@!f>XtBNI00D}#4qJ=n~bvg5F&V~0mrdH!L0=U+ZDL#z?xi~ z>UsW&rf9CI%hQ3C31Q;=W$*lD`Ro19n(D$(!KLU<8v)&_YMV_5dTxp}uiMbB%$F;p z>wWwSeEM6Gu5;)ca4V|!ePCs?uq}8zaJwWrReuQx>W1o!VJcujnmdIyLM70yFYRjA zS0E!5(!I%qjr6>X%$j0dy2+S>fNq&WY74Wb@zK+K@o(?=nOH0%pufI{Ji60)yeY-R$*{hzGH5$ z=53}%Znngwp>|rp_$U4FKC<_=VCt4GxkndAxRUL4(RQI*e5u2d1?f5}%?tL@ee|h2 zofkik5keiaH}?_&?y=KJz3Rsg@Y5FqkAEx#GT7llx_)}QZwyS0=p?AXMmTxgd+SuD z+?p<1+r?xJmfmuu<a9{M^hX7~dCiov+HVUcKwN?kXnt%RFw!yW>@6b*JH=yVYQ_S^`E*g;P$&SJZPYPt{ zHN?0@xQB}hE4bh(z`#6a++<4R3{G^0#xA$zlmiRntHd6FT#6HJ-Ph2hs%=Ubs0-<2 zut-K0-ZUg_m)kcc(GjEx9vuqNi^l0_8%{yq7OGSA1d3|DyJcz&RmCE+9wR>6M)8pZ gehA9}Qr4FK11=D1%bM=G^X9!6lTvre z*?Dg^Waq=4zI@+&KYV%nzVn8{nu7jsf$|7+{Up%!lR(!`0)JgU33LIpvrRzJ>K|49 zWM6d2k$S+jqw60^bu|P_tPBzaUJ~?*GF^IkA8FlDyz*p4Jap$IA4#*`VKr5hJjJFq z23ukd@vSkObc%bTs`hS_Vr@*MaxT-cZiD!qhdlFKP)e7c-$PFCDqeaFGsV@jLz;g3 zIDbCI?t0(H(tq702qDc?vW7qjMCBSJv$*oe>6Uw*3Q_iw^!sj zN5zM4G8LtCMKWV0Gw$=P2eNMwRke4QlAVWHr{gR;FLEx9Id>@2Wql)X|6^&qc=aim zTvZiy-DTYVl_D|2N~zktTM+_XKT)R}k3hEIFk=y*CVv9c1tmok>Bb}BzEjqlan?P! z>}Ez#?`XzD3zwpd0%JwG*@89nRXbd7A86f!i$Q|=jb_qT&y5Lth^Ro3t^k%Wu(O4< z)lmQ>MErJrJ7FdaRhMCvW5Vnj&^udy^%(6-B9AE#T$&hD~!Lcif9maceit*n#P1!{7<AYGau@fPDgGr#&+NvA8qmqVQcW(5N%knoKTXM0lsy?l wa@wA;m75G05ETLyrHIA=f=DS6A!%vp$6dR- zGiLvLd%dH#cl$B3ch~yMr5x?v?9A`}^Z%Y@5D#>K6aWDb_-3}RzfNZ8IOS(6gU{iZ+|Z5YuY|oNa99N=EEz5<521K zL$Z1-BvBArOA5*{g*rm7^DWk7fi{y<5E_%{-DtIZs+ugc#bFUhds2+Z2nK2(kLL5X zr-AooAs5E|2ny}lUhT#E_~F0J56-H+({L?K>A^w@ZH#)?h^*@-gpl4&<#%$nMy7h) zRgDFLzkik&z1Ihcr=260r75cx&DLr|9ZP zWPc&0#y}=$@ePgOA={tDk829t?6IN;g)C@MYdb9r6bEFXIn(O#0KFDuf!Dou?!vNN z1ZGjU&f(3aK(hL;kqf9%7X0~cnlUd|v{x#m}gV8B56rq`6+A{Y3 zK$hqD@#``tR}QMQqX&SW`X~Az;>w7)qJK93sg{M-0RNu_G?6S;GimbTZEHBx182ij_)2`!B95h$-jC)1al2r?bGr65{MZ0L zHW1T<9OQwuWEoa2Z0$I&GAkZnwab|E1kBDX%+(rdT5CZiAZPJ+nLL$ZE!KM{sG(|d6`umTAM$G+v{OnDy%jo^x zY~RE70f1MJh?_B`Ls@L@Gw2i&8ex>}*=p`T#rvxe;ZQ(Q%nrAbic6Ix*s@??F+x2T z;?|^f;1_M@Mv~Cj?ycsVr|yynx__>l?JZJZ9_kNY!UzOb%EZTICamLw_I({9Y>DVA z{mGUBq(E?C9qPA^T#ac?x}ADN7GhM84iTbm1*>|` zlOkBh2ZaE(%O(Ok?So(>n*hvKn}=ejfR%pa{dP^u0zWZm5qoQZR+K|~BYzw`V;zk* zvydysP_Ctv?K`pOddZfh=7;CRrQxa<6m`U|O+ZOe7kE=Zda`WiMsZ=-{8(z6k5*i` z%R)ZDX#zi>Pp=~FDebZ4rUwh9t)cB$!@s&{eSfXiXxo<2-Y(JG1)u&Nr3s*N(3vv5 z%YuciT@s5_#pBX-%nv^?$bU}^)*mfO)!Pf{nHqJO?crZ#I>cO(DjO|Ki4CKrm?!L%5L=c^YV*}cw%YR8mPrSBy`t_4A z6*kYU5&e}7uq0!TDJ8f_R}uDV`GtanhG_olP2P8dZdpp!FHXF--TL+~e*KPD?{jq! zLVL2>_7!AhyDbQpZcA{k)73lvtFhpHnj#R413&BS+vu7uvLwS^S|4R7aed5q+o z;8BkB$TdyKu$p<(*njsUd%l-GwTk2t;l>bCCjaiL`Pmoy+G!k5wrs~d!}-_q1AS!T4fjDwEq_M@!yWCz74}z;7(+%C zpQ@4!=gBetq5G} zCd|iB_rz$xxdpFN3Y0WQUpCdF8B*bo#D=Zbj^Rf$=#4b|L^5@greQN95o*>ZzH;t! z+!}=}#(vSHGJkUAe$1VUh~34^p%_j`ND)fvUvOlh9i^k7xY*~&zvC23>QR*1#o-)c z(9u>21-i2hg})$w11jicFBS-1=*%OLPu rEg5#=KIm4s^%AEkw-Cn}`2PTKiGRN@VbU4^0000i#8PWfs|(wT zFG3J}us--z=${~p6>LQCO^g++QUpPK@lgchg9Yq2-m z35d}1K)h+h-y`k^$O4cBgHsFG=(86eR%6rh?6p#D*zP1gzDW*_tD6O9i8-~vo_~nH zv;+3{7G4=FnSW6KUTpLQLiF{Rv&7u?h85x)Bqb~VZ2Y%P37{~&U1%umt%49;%ou+U7N3-?>NTsg3JK*BZ z0&Z*wj`b-pqodxT6#86?_~_n>SbwSl^tlpJd@^cp4`wB9e^Z{!3Iy-1 z6b9TooQ?)ExBqmz3bcZwr^QK&>uW1Uwvgs zj(=nYD^>(&#VRPL0JpYyhf-?ndi0f*!=bh;u;;d8TRnSbODPLxjF7-y-XXs^Cr(`| z7j0h`+uo+STjBp@L7!+{!$z|h$%>IR^aA|X)dKW80?2}aEPzc5i$Y&p>uM4V+v@ex zLR6DzE3sukj?eJ+1bdlWsejFSPH}#=X8GNXH48(3@u#<9qwl|y4nG)y ze5L2ESIrn;g4%FbBXQkV!M;Uu|DzRl)5|z>pbpFu)9^SCi3;n{uHR01$~Oe{w}_g`GQnxDyOFG zp312Pnl%AgFpvcx3kI?PWWhidfGilu0+0m*Spc$NAPYbi3}iv`4;!9uCL=g@M*si- M07*qoM6N<$f`^MDi~s-t diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index e4924417ba7cfb79b11632ed8325b2a3d09aa97d..fd15ff97b287ebb58ea20ed1b8c8d56aad213c72 100644 GIT binary patch delta 883 zcmV-(1C0E@1c?WbBYy)>NklYPzx5(QUygp6!aiU(Ti2^ z_G&<>cNXp^dNZj;MwBEgVhrJ0X4=S2iu~yQk#frtCglZJ0m;WNp_Rn z*_kvSOW4i4dEd-?GvB-+pa}#Z01UtupB%swFScj`(S!h0G=FJ;q?0_&d%9o57qL*lxOdRwyVhv+mj60j?A&wXYYs-{11T)TxK{O@4i5Nvv zH_uu_RXAjgVUH3Atn2M#4d6=d(7dC>Myx$qf1t~H!Eqb&9U{({&DFlJA03O{pVIA6 zjc`k1Teb9KfqyoFg=T%IAf#bp{r$S7gPh+>9~NtW*q07^PiLY0FTJlP;wlNvmN9Pc zvQF=YF!9g;dtc%@#KHb{{FG}*W}N}SQwnjaK>%Sw{FSLE>zT2$?(KY1EB5y|*9X^! z$F+P}K>RtU3Hk;)33QOeQRWDAIa+?i5+jYrivoQ9|7d~(0ZVryxz}Fs`9HXC zYrL!JihK3BF0mctSaX!$SCq}bzbWOpn0gRBYn{Au!;KmvIciT)njiK42ccn%w5m9 za?I=85PvCqXu!UFn2sh&X*`&DsCltWNAqOlxLWf^X+c##I#gbh2+zzDjG?vw^m0^ zAOXr_k%nA=hMw2_S?R;oIFDWFONhI#Gh94WIzaI-`KwpN{Rd@dD^XJ<9wz_*002ov JPDHLkV1gX%xQ74$ delta 552 zcmV+@0@wYC2fzf7BYy%1NklKi9 zL(r4A_Nb75p(n*lLA~`tdM1QMB|scFu8kG>uG^Au-4lwK7IoiVWt6>l>(H2u>0{9VBciWoQRa0 zB`x*(Xjd-u&UqLD4E*KuHPi~&E)`hf$ucHoXQjd-SAfmY1>8%Mwwonqar4L=3A+Mg z{70o}iMUfSP=7Ui$|o{%F@8=A$8rCsY9I9t{=*Vc&9ADB$x&Y8pqsi0IH6QUaOxijh?SI1bX&WA9#mwjY5Tl0qd=UiV zmxDs4ua(@Z<1aoq2?HTTrvt8kFV1MhirFjMu#n)R7i4_&f}27Wu#6fk^ia-iVIos9 zKpL*l?k4_b-D&v!uI#w2PX^&ITRL&wFBjr+e#c?hIt*T}kl78=SXc5YfO~2DnzG+b q?LWrt6d`UabaxDPusqm2U2IfE6h1R|?(VkREwvUbl?VZXNhLP&lM*7D zV4{iW18@4|i-`|vjESHjU{Ly^4-%h@&&CI1c*4X7f(b1c3@Df=N@x(oKwE3+-*$iQ z&zL#4x7&8_-M#nD%(BogH*LCmJ9EB!&YU^t%*Psug14<>|ZyyO@)0QjV|zkVSo#W#y=YqTgLBYFnZ^3Boyid1t>5Mna1blNbDK0FqgX)!@5_F-6-a8uS2!zuZ3^pxW4|7Om6~I)T z8|x9yEDC*_95zNisXF7iCgF5*TXHpQv~5yLL*az!{eQ;R!yH;=xSqoa=jxA##Gtz# zg|y+G`CCitMI6lW2)F5s8FMLiupbCm*C+WcJ>5)&hMz%%Q#cs77D)tJ7*I_4GlsT4 z017ystA=C{;d>Y-D};?1WfiIjOC%yqhnawxc`0E-Sz%!bD4{#L`S4vn5uDwS_B82z z*u6Lcwtwm{y=rD&O1M>vdghhrk=yyF2MqkJw-R=A5{yxNjRBa4=yL zpN9_QQUnNeq(g65-70=&Q(Ah@a)*`~$rc8$9!O^Oh6Q+K-?14nlFQ--rtF>nW zB$wjT$!l#SYw|fuSu9Y-xfa1HztyEg?9J_T?|*vx*g5yoXo7#IPjs=5_wY-j*1(U- zWb(6RHvfmMtYsnb5gva^vz`d z?tcv3f`>Bhm2uT_34>-LZ=i5J33bX)sML8|>TMU|S2GIyXxB&}A zc}qBIWE6;H|z%c^9!VJjXU&@c2P(}2!q4jnjV{v z&GAz=^vfY?dQ9scp(ex%^U~l&S}RG(T7M#~=0#?unKW66fyau1(GU%j?krHkZ`ay^ zDefVJ-;vy(Ci<;91hs#@_39zWGBWPXNa5b@b)U zgi$`0w~h?)QB6x*KA6yJgS<4KC<;9v+e3PC?A2cHLu3CIFLSUC5Aren+h;0eTD!9J zq7VGPf^~Sv9C(KGv{gPF&09wr!hhE1ia$!Yh^mAz0prC@e5&N04M=YJM8P^dxWE}L zIg~Bk)=k#8J12+Ltde)kuZ{X9t;koCjUDXuKF*Vi0aE`sR)7af3_7wK5iQNL{?yqCkXBVI#p?}_%U^@+&e5d3% zmmK@k^KNe!?eBrL+4;@FQNRJl3+_+1-OEXK!&J}~x|csZ+9=58$?lbLEsNT9k@gQ> z2O*rWx0x$wk9AYSJ9Qo|bIo|Ngq-iA@XJ7}ta+-SUI1Of!+mr2180i%Snmu)gp6St z?WD?0ue1m@_%APt+hTGcn3kFosWr}6P8iW@z3jOhxb;5$krDp2`=N+JVScn z4Fqw5*Z5igGlus&&8Lp&Filnn`$+OrowciQb|O`8TFw zL(q>9DL*1t$nsveknvAZ=+ibst#Sq9NJZtY=%1oUBlEn{w^Jf?VB@Z`16Arum0tc_ zLREN)XgTRQHv=D>cYs;-$s!H4*@faZ^{w5T!+6yVUNGZ59qPYl%E2ckUm*{{|0thh UU;*`yy#N3J07*qoM6N<$f;;u3s{jB1 delta 1065 zcmV+^1lIfL4!sDFBYy-0Nkl0a-55`^*N*lr83oX?K@!HVPyOyKv zb$50?v)3jNa@RlW%rqJ2_plGUGqc|>v-9oj%sS+MhO^oM=zqovn~oJW9V=`)R@ii` zu<2M~)3L%@pzRr8H0=3X|E*U;I92N$Z6yF=XadKkHQ#&{gkg;X1dtGPnf|-mF>E?o zgelNnX&lUBzEtr#q-x={I&poJgs3_e9m4GG&Fteha4;Vn|D|#eM_cK(4gA6%;kQGo zgVEB%&nBWQm4B?MS|C*$9leBxSnL=!9W}xfz)RrNTeB%c@^hFHVfXM3=-9+h z{VIOHAEP?mUhkZ2$APin^fl?Rsp;Xy^*{kOuOqR9NyY;Tq%kEwFlA#H)HEvro3uVYU%=Tc)@K#4Fve}lF>AO3tdUUiYZ$A8z=;H`1laS>I*I5HWIOvbNY z;^C#+G6Q-7JBCe1i*V5=h>Inmq8}Y+0u8a~5DqVo;0(v%LbU??TtB@2oVc3P5R8@{ z4pA(+B8%fQIId~Q){bG*vBIWfg-yo_D*zh2=w+KLE>eo2O+W471G4QlA+Zow{%kaTW{56AR zqh5F}3_5$+1iR|ELOr!s5b#6H;p4#lxY!O1H|2$!eI!KXd$XC2VsIY(N> zH-q!*>&~7R>3ds&0_KWAS1(S?Y6wPCmd1Q3=zq+*pGVGYf7AR)_Wmo;rO{&0nZ-io zomA=Qdo5w1n+^l#3Iy{-Mic$&Ep`+>}ct2Pn6><)Zvoj;h$XwCTteezY(XhWfSm zA2uH#os9m~10++Dr`D2%@t-12%tGBW?42$A{EgV{uzigL0H*`-SJlPcc*uXG!|(5~ z51Z**FFBJXe(CnYR^aksz&?1DzPkkpihrN}TygcKP$2xnFl~8(HgBN&n-Y5)@!rD~ z}4Zi-3`0_uV)MV+o zkbl`vzq>=*Hj;D_oaIg)P$0QKEw1InU|vaJN?%K2xFD+UC#@FtJ#0Ew*mSJ0=^|KR j)3L&)V}(t}3Y*TqeMEUUx%!E_00000NkvXXu0mjfc1{_+ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 088115a6ca48cf41a93cb11fc9c20c5998da88f7..4eadb79ebfea34d172e8a68b26ad57faf477c31f 100644 GIT binary patch delta 2971 zcmV;M3uN@#44@Z~BYz8LNklLWo=fo=gfZdpMU@Sw{s2!0g(hy00@8v z5CO;lNccGeSi%nhKn7?Ah~Xy@{Dc5dymA>}A<+9T2O9JZAY`OWLJ}o_oCBsbyr%4B z3O)mdKh6Ni0;pW>+7$vNx*aLbA}NdBHLI2~8v{t7yaPbWyIx!(2%;r`7JxXwj4!g5 z!Iyagr%}$sLGc43IR`|d(bwR<=BHau#|W^nk!ShI^jcf6a{(K zws|)MEPdSrFwaGka3H!9hv7}{8}wiLkAg|xBg5doCS6qM9J<%PTmE{7i0JLHN#|>N(Z5hj(nPm%0=&ZX5jJP4X z4QIbirI#x~N57%Vg&XXc=oroho5aR&0u`;jgj4(48PO6tCJk>dyM>04)i@6HYH&t$ zyC^J^wwU~#U4%;7D`(kNm-7)1%wed{8=|{|On)A)g8bx8`78ZqNkMl!_mh7IeLIM0 zv=OQaVl|=@Xv~R_=tGd6c>Y~78t|gc}n&y5_m^Q(6V0MY1$D5^iEFs2p|Wh?Y^c z2Y;HElJ0J!haO~9GG6|#e!6pA(|xC+;P8w3zna6N3;e(Mu@Qj))e(;Q~d3mL9zeWI_PQWBtH`=*Rj=vwvbf z(XS8qO*CNO)1DG-qT2%s)Wwzj8<}uvzhG7?LD<*n+l6FB5*-#1@PKVD+eC-P24^7A zGA`u}!2{`hnjDFah50IOV0j@%qWR22A@aa!7>N#0LEl({!A|qdBA>;p^I2f{Z-M{f z*h)ad3W*LbvZ%fC5xpY;4D>&Jz<;BC{nhhiYb*4`_1~Z7(Y_<7X4M8YV;{jiY<6u? zefmv~?JwojV`KcVul%9F{Kfh5-~52iJwD6AWk=>GuOB(V?s23#g#Zx0-+c z#Bes^mh4E4E~MbdK*jV{j5jVzdp`#eS%2M8>{Ng4BDt>%sAT-}6aIJS9)G&9M1MGw z`}S+NHJc*|HQv3Md-CrfOOO+Cgz6$shMG$ypfk{gWErb!sFP&@A?k%`bp+=YZYqee zW#%RTHK`7hSo}fF_`i62(>5NcnO+Hr=8YzxVd%~eL@XU0f;Xg)W`CfG>Vh{K%%GAS z^i6CAFe{Z6f;Wg2>rlk?WMH6o&IPKNJM>Mf?W~JVl{1! zG_Lm)is*0dAa|N;+33iSHZp2_P@QYD4tnGfv*sLt9voDE^|mi}>I{mx7$2<;!S)no z)5{FYr$|Jm&+aq1KDXXFUTIsB{(Qg1vRN!ME0}B0Vy>kPxPYcC^nbrzknZgyyL*8U z5?AQqA#HfnIA5M?X`qdc43QS=&>Jw$OzD5-Qj1eN@VN_Fd#4-fi*H8`?}wdT#Nzt7 z{p#Vj*rnBbgGzU{(BUD{nqSSyE~K>Q$5~de@c-@FyG)f$JpzELtH;KaXZFFaE>o@( z5#`7M_2|jsOH7S1Jby&&HM7RWIqm2e%Lt|it*Yoe(JJZ@p@;@xD_ZWzsB-8bvb)!m z>t;oHZa*yBJhn#Z(F1vn!I*JoT01t*vf{C9uMDzXa3PcQpc?8gCzL}El0AKvzAmhJ zv6cXV#q#?z+OaX=G=>G+AGC=UO-&b|nfh889+Gx;m$^yC*nh=2aajiYWV+i#&pFyt zEEs5p_WZc=%){2)SooMR1{delBPT`F%N7#1w@WID-uY|fNxQn`gM(~7qy6rGRjbh1 zvYHznjU0Li_Lw#wSTtK2!wJhWq2Ilq^tT$Xf2@yAdi4{kqYc3Bt%&GU`K?npfqw6P z(w{ULo4lp}<$rATFK9;Y#R=u74@+O(VpfWsnbv+W#+;SQNJDqY-?-b<^4mM~lb-?e zrIv-1Xow=Bb+kQvTMll|W+V}*cBu``P>+wxkM9738UMKCkY%%r)<}LFL}hc}`lHX= zOEy<34g3q{B9?(>=&zm&`X-#A^l_FFovOIWRS0)Ttbbldu_l!j=C(XReD%`JHN4KR zw;f>!N1#r23CrP>7@{61WlCkrX^Lf>F?pZITByox;-9rt>@FAshf4@*M!ft6VC9I` z!H1<q(mn0Q zC0-+lB!41i4#DcQLiWHjBr66%D;pVee3Q1uiAgv(XnJcBp+DZM{_F&o6qH2h_rF9i z=EoQ_PF(fAi(=?CY-_CY-Kd*QruBbamcO>m;OrE6WYdA^pjikw6L!+X-su7CXItvzvNWQbi(8|P-(a^2%7D$4_H z@P*ENw2uL-X!@_;;TYv;s&aP4x=0AA!yo0b+{wKvEt^df!o2 z(~eIVH+b&;lB{y}c{N9HuC7D;s)Aj;r9S;8ed+<&+gBWG_N;xz*e46xOYiV4scEl% z!hgI2kNY}jWu014kB>_`I;5}mlU?1Ux7mZO*%-iFhJ84rzcp$6=Q>~8xz*8O2+l$q zOWcD^#)Xt|!S0~`J)jN#j){dB#+@CkY;#8R3Pw?)( zIOubsF$$0RQiM}zT&IPrW9I^j zUR$j!*PV+^VY-**_o8lC#L$Ouua}EachY+i16K`v@?jfljct#jVs%M~RMN_8~2{CZU+`XM7DO&NYo$ ze8teU$%(h8Py(emtwsW; zyA=P*!Ziq@96E=mtx{XSb%0x(PXLxm!_ zIedN;A7s!c`iN2BUtHspKuMSINm$u%3Du6JQdbFn3}35`%8jC45-iU3e-!2-Z7`n; R5&r-H002ovPDHLkV1fv$#0vlb literal 1626 zcmb7_do&XY9LL9EBIGfztx4t8YnDeBZ43#+6scBHX^62(*=pD!&qi|dX5Me17nRqd z@}4c_HLqI><+YHF?v|^2PN#GK>fAqmk3W9r_dDnJJ-^Q{&c*qNth9zS005A+x3f96 z5y?MHN_@lDN#;iZ0FqkvHfT3;_Wamsg7R^-7mLgEj8j7%T~((YYR`pB-cD&q?vp_H zk-wmw9NE4`2n3=9qzIHsQW3L`i;7g+K@4SsydvTXbaWKK{-<$b*7^sOixM5 z-cVR(>Z*aJX@xT(PZuVOj;5j^#N?&dN~71OBj+m2#=7kb_uh)W^5$do?dWSY+D(tP zm{E567D;L7N7&xZOOnNdo*WpO%-j?Ze$H%sbuhakH>dn<;^I)7em5OJ6Xa!@o07D0NN_wt!-M6 zQ=wm-c@iqSH78)2-wX?X*#gLmFHotaO4Ec7I z=*PvirmZz7Ij*d)m9o;-kfrLJHDPQ9#M>&4g%hQF&Y8(c995yka#Sp)evIlL_hPKm z5|>H3bIgSAOdfKq;f|KlPchI@3rgj(<$Vw$W@6y0rGzprjGD>n^wCkZ5KiJ7he8!g z;?L{E@~**TnlR`n6eoooK+@m}Jnb5D+Q>q2Y0;P8stUI8MO`U6rA}fc5!XFnpO?OQ z7%ugImox72rzEfQGgT=D!EO4U zNN8CKylJ;{_1&YVN-NAarDK^Y?q;M3XMf zD{!LRY4_u!!*1|I5u+A?^mcQ%9C8TL&l;h7=w2S2E#Gcsc*M+@=>ncMTprDxjm5jS z(JRbu%yi|2H)S+@XUV7Ss+ToE* zU?r6B>3gVMK}jdhLk=&ofZ9cP7#@mFn@*U1VK1O;hK-%v@qSd~ zC%DICs+^C%>x-1fV)g6Ooa(?Q1N)&DtCEk!nA2x$!S86^fmp~B1o03RZ#FwqOM+d4 zt1>7Dw^3Z1YW>8L!Iq5L%F)q<6+_z&gEUrDD|C%%f0VS}m8ezq70obKk`8+3=AGTV zqj~8|=U@`Q-D!XmML3HBl!XS23%Z5nn-}Qx_?9J8Yq?BW5;ZAkBIM5W5eex_Cl0Ey zu;(}H^Rw-}7*K>OCHp;RoMfcck^P7uhanSh#7_VxpMPC#?{t$BYWKc=O!}ICL{m-iw zF7p9e0qm>1iMh#3@j#%#3HXCSQH7A)ocx*z##-rJPsIpBOqSvA_XV&w__4aU#S`}t zHpoiE^<%Q>)DEEPfmOxEU4tP$CRs)m>wn}P1Ht`52<5%lZ3XYwoZsqCH*%jEKEXaLXnwCm%B30b7 zMXKOPo~J(O#;wof<)vL6>Vi$p<;rN8HE8y_ z46UByL-e2WgmE#t%YA1|P|``D?==>Le6zr^0q2K;>_by4I9<=T@^r@>UTSU3wVKxU n2yqen$S+j<_9p*ZAd)$`?M(&fhC=Da&j8pTcDAXuIv4Q|fdlfN diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 26e91124f4550f161a5243c6a7d251ed33778bfe..eba0c613bf221c4c29a8f25c8dddf871ba988a8f 100644 GIT binary patch delta 1102 zcmV-U1hMy*hBYy-bNkll^sYBW;;Iqx$HIz+CA;Wq{J|K1#`Mf|b-kruPfvP! zrl+gA6U~<?12FnW11JEbc190UBYyz+MTVEw-8nstj1H}y zmMZa~9iK6l10d+D%3}0T(F7PkjmXSiH^34AE{$GjJK913kKn3AmIs%I6^-JR07}6% zXr9$DNd@%O0GhE|m|_VsrnO+|$fIdDX$g(61U&;6d3DstBaxt=Sa6&N)RRunSQSjEg-a`*fpb@q;)UFfv&eOu*^`&(aMf@RBN!6OVm zi6si1Es{Y^z(%*Lai|immMg5W5*V`mr}yOlB5Jf3jDN7Wow4)r&En`T|KuOJJ)Pr| z!Nqxf|CP7| z5ObF*oVhmYmKFn9Ac_VvPxZnQG=gv+2g;wihW~xA#u6agIf9iM3Ln%>I^v^6`_aSQ zlh!+!lz&cSI2|b(5lf7VwwX^#tw5c+q5po9Y}2yj}{LMh$ zeOYQyRdTh~OzSUhK@_*vp?I+XvGytzAo%Vr{HsxQ?k;$Vh+LJ<_xpm&kNbFop~5Ji zkwBUVJ;f)sI@1UJ>FX;@Cnnk0FkGB3KCUjeiGMi%R{eZaP91Qj4~7?cSAk0rwsyHea4^@Dmu#;T$BU^7s^%@*IjD5Ypco@ixW8(nc|EV%Fp0$d^cB_so2 zX(k5Z@VLJG7#EGRmz8TE#`Qasx5kF$;cbgp zK7Zo%XQo)XgXQUX(URwO~@8K|A#Al&j@VW04&4E5TL#s zUc8jSF;Yw0PtOgaVP<3+^}UyHFaL}As+g4BkUEtpblJ%zqWm(A>F5reyY^b?IkeMif{ zMKwq>do@lme0m((0<D1P{bbClbxMcHfn5Y z+GO@4k+9EgUtWIuz|79PE5m<@+-iZ^xLP8(S|YewBDA{}>3`k|KyYEjmNeV65J z5p8(Xo-|uh%1IQ6ePLDuIwUCS4HZSOrlm^i{Zs7jYyR?UrACi-%U6z-&OL@7tVLQ1 zg-!{&)T*LKB`O6~YMqv2n9-v9w@ZV4+Km@=JC&Ou{m~?Un{0*_?DmoaJB($j2kG~d zSQ46~Vqko5YD&dfJYwWL6_ypKv?fYi8R=!Kb?Ve!C5WR>!WA(k!PQAF<&Y2cuKoE0-WPquBzLk zN@w@tY=4$V=bhCmtJSFCY*wJV9;URg;dc>}f3)ISO9cW*0^4vQU$>X``Un7~#5R^0 zEllM2`?P$%pE1rSGwX+~3WU&rPrf+7KV@+`Q@h7TEn^hE^GX>CD%Xy~21N|SDuAiZ*$slf^ZI3O9WR- c1XoLhpWRUT9^)S8EdT%j07*qoM6N<$g1Br@AOHXW diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index f16eb5be49a57fb22e6c7a5fea6c38bbf26bf24d..c0c2baacb57908196657d5effbd74fcf50d0e062 100644 GIT binary patch delta 2411 zcmV-x36%E#3iT3@BYz1zNkl6+ZXQomubN-iMP$Nz=q`>V#4WBOoe7 zO&U@tt;A1MDL-3LRsB&wAcVxrKmeg4Ay9wzM}#UNK%lAt0g)=G8WJ@`rft$TEp?KD zp?NoUyx#Thye^tEJNB-bAP{kmqA(>1keBofC<3x z-2fN>sNezM9|r++02P1(DELPTPzP8@r^!s}-Lm+dO?0c3)b)jc-bP_9RQR8Y!MgtN0MY^@cCQ*2GTuv)5Wv+ZFKCX$sifB zhOvX___U?L?SH}=Kmotj2jcG_nyevw+Lgq6gfDb_Q8YAyo>3gck(I;t-9*2eI&3sF z22euoR*xjHAc(92wkZM2?+PZsEJhv$95lNgTe>DIhi)P{7v4P#wD%!A?k>*gj;sO> z;cJxL;8I=V0o4?-XuSwri~rkdf_%SHfh1fLs%EEV6^fuHh&8%bn>ts#gkH%gFM zJV*sK41Xqq;)x)#2Bin+3Q-Q;Lo~(w%~@EKn3OMKXN*r)F@<=F?%&QHx!XCandYz0 zO1<}ufPBcGtQ_`a>Dde^W*SFBs_c8a@qOOcMEuE81y{~`12z^fRd0r=6+UDYkmFku zWi+m^WM$WBNEIH{_U7V4R)`2q3@9O+a{O{|-G7(dmg93ZFr+w9v%RxkMOIE?R7F62 z`qN}*$^PBH%wN6L;Y8*0Hgx7}qMYNc--Z2=W;+ULruqo~_W4 zEV}OwLI~e71e*up-%`A(-6mmdC2Lr&jDX+^y!Q@ZR7QJNU~y&5tCcLxn+os4Me9>+ zB7Z9*Y<_M1;HDRxBbvz4Zh?e3Dr`^cglHlw;X#JnF|2&yLwxG8b>e?QE=VUfDf_pZ ze>u<3B{_FeU#>oTWJRyuM+JLqf_`E%+`r9w{R&hBjJWLU_mX^u?JOBzeo;gNeV}_) z;$^Y|$D{7CVZhM{DHL+55VFXy=Dg=M5r2*A2yf8y$XW|(jx`Hcs%W`n<>JgZ-a@Ei z5wzlxMf;W&1XSQ4S$(Dgff)pnr39WU0<(1(17dSgnJ`~8Nlt~TlvEZ22z~)XFUJq)-d}B-?hMn5OrNHDBkyoQ? zTF;)QpSs<8{@u;cgb3_n*<9`YV<`lE~WQBo}Zs^ck$N}qnt+3$zbSDa!q{`nq zK;N<=z;W;rW(<8m?<~^KjoB~F*zX35=jeEeJ}_#%G{dK*DeYE*r8rxmd}TKsE6@X@ z=Ajez+3Ow7q`OAg_xF;VNUe04YGo}6Lei+R=e0_6ITl;cs%43ROEE_-lM>=g) zmnK=$^6?A0@!ezU$wOr^JoZ=g{hl_)vtfU<7<;JD+z#tJL`!{0Bb%oMB^vnAXI9m#*q>@*kc z`mgWpg}aJ`G#(w=RFI#_GEyAimy%Rjd$WZeSqm|@ItT-0&sOCdpMQj^p+EH^%=+30 zNBdHLydSpbn{28hQ<|bZ^B~!nwGN*#|9CEu*0bff$|K9h(J`SQX4i;gEQ1=ot7uPM z?r@^xzrKBpJ@HZCU>!aa*!#$~VX`sj_>f&Aq8sfYyR1uY3bFJE>(CQKxo-aKRN9U3 z;H!hLc{@Axv4nif@_(Jy;GN>DFBMrr>tfngvSwQr6x`~_tXwbME-hq<2`7cGS0aSF zj)f->`)Df{sjecc+U<^9ZE(F|t8_=nJ}FXuMz`e~F`4j8s9j3a&a!mD#$~_#0q2|$ zLO(MKzkkOO&j1@1)8j1L~J{8$Uk< zB7{Ti;5~F#$+`Z5ZvXR&h?aWDwqH;0Q_8C1W!+VOv7AV`Qil0XK)aM4icwA*UOit-J?OY5wcXY8p<%4f%%qSfs~z~b;lIPhy#{@NAm=zCmj zNq#6mM_d6FW8R zjHbl>9wb9SSJi3WvVm~3U~`x#`Rx7E+aQ>+gkUPllwloUBW3{WsfI&v#1F?*#QD|h z>&w0rbT3iTq==Kzb-Y2X_cxbR!}_UaM4!2z>;3UDi#5)OOvRNsxEoapujI(VFqVR2 zEtQC?34d9{R|(_Q9&%wLI7Y=yy1Nt5%wo^yy|CkXR=|#PU8{|5qO%;t$F(+h3RI9L zLqJhLgfz>*R{`5Jzs4qqTHE#TJ+~SuM)ne@^c^r z$2$%Esc5&5j6%{4DRSs9>asEX{O{{!u^=k(H1Y9jyu002ovPDHLkV1gbQw}Jow literal 1407 zcmeAS@N?(olHy`uVBq!ia0vp^DImI04tn#SOSzDWQ z#LDiGm(#_FWk(OCJ#Ucd**(cX`Blox*^8B;x_qZzd+E31;i1PYf0zE==y3foiDJdjvJZ0*0oJ-{o%-DQ(|9lI)e6nqbYT5y z&ckzaA{Y2g`&rlj?MGB)7LVH9pWn~iz5A$f!`;ezQ*&+t<@OX@t=W9~XiM4k6?aWv zB^~O4n`krDG_{FWf#Hmg|F!&kpKSj2-3pGpKKa^i`vZ5~N zuP?AxzAIO~y7Yi|4JoEoZ~~Z>QIJ3$MHzyDB&TP))4K6qWgwD>fXftkCrC zQ{Avh;B#qh_2!?yICnq4J|!_~OUUhKGnh30@AsGO*}l8hI$24ms3tet>t?*4|Jhkg z6Ap);y?a+qlsnG<1Lxrzg(~Z9*-8)2{#*L`cT~yK9!=gQTi73`{SKVzQ5C-KdHG?^ z!!Ime-Ii?jDA=|?<^P>Oy05wmFJ~Q}ayhtg^Wo%RO9gHRwMobN4)6OWaqneJzl84G z+Sn`8&#?0JNLwaosi#<4sr4+m953yv^5y&XwnvspUUPL~L>pX!jx87ODfsoeV|}gi zwbl>kbx%fq4N>5CuKPK;>+e0SluX^kzpcNYEXmBV)i}OftY@~tnZD$G*P|9xxo)!! zUGRMGWwwJgulL4f-_QLo7w?&Vm2K|d*QaYFI8VLTU&>kXZ2ffp?C3=aH?Fzwl{BwS za8+daEGY20DNlSZQ)_Wp+3mlnN}2myqkL9cyG;-l{u9&)&UqrICY_sUIlcJRA0FdCm`Wzh_4fAh{zT-aLvQ2O6~{XI>^{GyxQY&JeGB;Xp&`^NV9EkB9I zM-NZj5vYAB*S@orE3(Kyul;d^^pQtQ_c!+*TNj+LyWr{z|MlXbjOzcVN|(K_cb&F5 zJ;T5MpBIm5deuB>#8?dUZ(P4S(xrfio>pL*%vn)`My&lzs+S+QF>>i4^<(7g)n{(+zT@=L>a^Xr;!nE`*KUov6WunI@k4g^$s{k!X%m*$Z`>yrR+)65 x+x6v^)VK1RzOBw?7k?;fLrhVGRBS!qpQ*OV#plV5-@t;E!PC{xWt~$(69C=^lQIAR diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 19c797761a2bcb472200691bba4166ff45b9fb76..b99ad6d0dde5746aa68a950a4cdb0154d936bb98 100644 GIT binary patch delta 1277 zcmVWwl>M_-tNrG z{^qvXz1_W=ncb}P%MxzqCNrP?&2MIYzuB`;K{5DiArwR<9DgAXN-Vg=aCH%)2)Tv2 zvdj@}z;fW$OI7gU>MN-OHwW}8SQ}tMCyQ^WoajRxA%ajRHW~`jVQqqKI(m57lF$q; zWnwi}2Hv(_2rok(Ts2@-@CpUi2DIo5uV^S23{fB6#Xwe!(6%iwXIdPtCisKMt(DIz zpS1$er&@SQzfWW2S(D##?4`^*rIW5^ga zesP3-e$@M7Ay`~BZ$(-+xt1qcnbB^P6drHkks2Ov6=IcZS#lvuveK}JC6+;b-{5XpNz5kd4K<*pzSt0GsEX@@^5~N7t`bW ztrM@fA6`N$rjx+mG;PG1a%4R*62?vS)J)xGA&e2D<}G(O*-YctDitlXz{(7bN@pg6 z2OHx0ib`dCU?-V;j{k65yBil`F;}wMhD#Ow;Bc_?P?41$Ze) zQR@$5qJLgHDIVflp#v04*{YBiI%=`GweFz~SEvWt@Pds3yCOkWNNd_c_tZS6Y0_qY77%Ge877{Y?TmNdP3xU=o>rpfrll0fCO@+7Nr2pSjr z^lh=Y%I6YcUWn@p`u@w}!cuzIrSHB(-r47!nl}c9yjI^0@WEZUoAH$X`5XMlJHc{B zyn@@$WM&)E5IL;{gv2x)isuHLrVmo`0Qu z6~DO$A9`U(Wr2dL%l_GGfqFX0`j@6u?9xdbc@EJd*ZcBHct|)>8?vFSdHrF{U^Oz< z(5yJi(LCbxgkJJJR1KImDW}R}72weYPh^c%16R!2N^{~@k@0Gw3dwiBInI(FYm3C0 zaut$8pC6nI%nJnAwVpdDrHR1MJ9b0jgkd>M5aC)|cJyh(3al`CYWbEEy`T3Df2xu# z8ktk=>_<5_3}Dg4;8miqjqPZlbPf(R^VlF7tXlMoAgypm{=h{(s{Z(x=G@Y*82ZMs n@K?kj>?k`TP|o$AEvf$i>Bp8>v~goR00000NkvXXu0mjfHhPKB delta 729 zcmV;~0w(>C3h)JxBYy(7NklW;dhuQmdJ3LJ@TMRwC_cf*#R_5(T2T}!jWxzfDn6nmr7zp2$<9OK#Tb{c z*_llVyT3yYU%uV{k7afqtD@RL-D-gfa9!=eb+rfA)gD||dw+1YD}?-7?67`ek|!7C z-q{A%mhDjQft|h@aJ3INpfSR zD1Uuz`p|f_T_M~E>joQba(xEI=JL3z zqR_vZw;X8QmAyms*dYw53Z{{xUTk4ORm2G^cLoFm}LR|00000 LNkvXXu0mjfV~}fO diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 35727e3105e58b8b58c67e2e30c1207719d18779..c4298a3b6192a22c566e98cef2f6d04d456c4d25 100644 GIT binary patch delta 2859 zcmV+`3)J+04Y3xGBYz6`Nkl^_Cris#&KwC->Y6Kzf zMWqPTN_hZYc;E>T;t8Qj2uMI}Dv&?|UYbHE>KjPBPzezc1f@+*dcC`IIcWabv3KLW%&|NQr(K!13U22g231Be2Y03!4) z0W8yh0)PS#2ao|w`X|zFCEC^j<^#O?8YlsuKLubAO$I=LsA&$(;dd=$x|;+5Bf4|^ zFV;PRpj8GKBz6pdMPh*q4EkPSb7kx=xOiH;;V-Q*daok@Gvd#+!fzjmlsrID{7D`7 zLaPk0iALpuuYdPe!6JPCV1TY+(HYkwA(&RlA@r`;^SHtseWz*Cn*%5dJe4q7C4elw ziF2aP&?a*9QR#V?NyiWXF|=-_Y260_+BP(jNJ)TUdhm_3i*~`A))XD`7U!di!V-Ow zBo??TWL#;r-}*eyL;8Uwq9H?9&Pm{MqBTj%(``Nn3x5DQO{!^9{&)-v9<*jilHTEP zq=1nlT`)@&n;jjgGg@U5`BVHXrNCj(J2yh}SgHNPbU>>@@BU=_2M8~wXzs=KjhA+5 zO_Eb%w#~m@2gsAv8*fwZZj06weRnaZHWq0fjj;SjFYrif zY9n!Kqkl-=agSrx3$01|-cX#{D3WV&6WP1&;F?yM#5id!pm5rzz@Ritp1H;K= zXn>})4t9lKN(7bwHUmsI{C#6uQ|r(n?I&iMX|j!Jm3y={1tU$XfhcG|t9L7SBcib= zs7I?DM3RI9X?F%cJz}1kU>~*)+Tn0gdTKj+|Bg8xP?&eVzUftu*8ad|DjbL@FO6bE zW`AGWrG4W#D>gM~5Y!hA;7AfoR(}2s_CXLC2_=9bH?$&+?VvCmhX|=qsR(3ZaI2|w zPe*Zc!rG7!HYXtvTI~V5#-d$AYk#nLC|Jp(L>K!?2(I=M42`n8YDuf5Rr2|Yx&@_s z9kR8wW;VQ=sy!5lx`woh&rs}I=uS>Kp?`|jm{usOCV-@s}(5@5VVExdk?@66nw>6WKmm ztJ0JABLZxS;$vIj^~)TOj1OmVs}*yXzOWPgq;;PBs0FB^HQUS|BLvBnC(2t%QhzE6 z4XXZn5lf_L)zBK8wc1s1{QVBCAyBKk0R>8**KAr-e(L~Ti!zZ}p;Zq)TGc%u9ZP`@ z(gr(%<|O8dd!j6`!9t7%VO+6GcX5lQ2C?hB1<=(6rWvTp>~cU(qXZ~cx=IjgfOd3D zIzI)|1@3Wwb1JWW@1MAPz&sN~#edDRP*@z_;qG3&0B719WSOnb%t4pC+#D;3t>Ix`URIBI1 z=8f=a0$l*hG1!X>aEH{RRRpl-Y7v1jT3KCz-e?s744FBA*#zz#W@o3^yzBT{Ix0W5 z#e8p;%>AZpjUs4t9ahfg*yUXkugD=$5ceG%(Vk}7245C&SKR`8XC zfZ9L!Z@t_!hfaKP)T_C#-QwoLWKblYbNJS~wbxD^{rx z(Ha_MKtFa4Rm7)vA_Dfu)E5tEhfc0#*5dTY<2XdsLyVcHCbVCj4d#j)fM^YmGQdE4 zc}z6{pV?_u5oO})kx}i?Th+TrLkZ=`C=MrxQpVoCsr~XSn4z(1h)kk&!CTuLv0$LR zGNzVv>A43iX)q8|kAIA^+ZAU!X5-3FpTKOKc4X|tb?rCjLd2GPJHy%)ATeI;a^ zR?+GF%!RQpgMM^OMZl+bKozx?JUulLE_rH0SzAD)J8K?@hkx|&ko?s>>{ecXb$m6; zwJvA|i!i`vcGxcz17N4!&QZ$O9>R|gn#ZpirzUvbKqCyTwzaA|(re1WQv|4E_1o<*#Ek}uziFdYWLNj1rw8ClG0DnW+TrAhO`|HtKq%NhxXf>}d z$UFM2JAZT-{%}#yS@4me%19Mj@{+0F(Y`?}YtBQ%Vy|HTo@7sKM?mSL8TnHq#u)$p zaQKlv>2up`DPWux*3mcKaPT#t)m{?RUBhCpFy5Jzrwdl+Dn#Y^ee7pv%yFJNDQ@di zf3zR%et%je-TeD?jt8%S(y*khapW~G%;+*q{pB(B@P3dbh%3sE_p`H;=9ydO%u#L00^7N=*@AZLJRCa@o0FJ{bvj;`bT*L_sHNI=steWA1+6AM9MQ3Z=_NKD zntyDcMu-v{E^;c|%3$P&TUj? z(Hp1U>#~Z}WIK=*#)AU3v!spp{MPA>G-e}hE@RnN+HjG4ve9mz!Q!6Z{S~yEjlJNU zHDnnZ+z_Rq+HUoC2TAVB6rK@gg9V9xXMaifcF|ewfJW-U07-)ouG05ta0if$FG2>M2bisDSGQ`*v4s+nD2Cz z_iFI2%UX1X?cMdD*vd}ZW!~dJK)IVeY~vJ`m=_Wa;{eGSq+*t@5LQLFOdrKrK7T@7 z)`3^pm$W2yMIgb&nL0^>7~P2;w9HL93De>gZC`3$i}aBl!z|~o*i}dzEm9toUygDg zONk9fG9<{1u@~kN*Dgw-M`))=`n~{Q5Ea@5N7Slx=Z2Gi*17zRkRGBrc~QYtYX(~Q zu2p4xMoIpb>57<>>!&#jc-~`T#3X4qXUQ__Bhj`NQ0L?|_&>a;VGrYv+VB7X002ov JPDHLkV1j6#dmjJ* literal 1664 zcmb7FX;6~~5dBEyFqR_`735L~D1<``32h~EC7ePc1jGapjL;;2Z4E~ix#dV8h=PJb zL{z8+qL>Q-DM&GbKtzq=?f0>t{8Vb7j|enFEdW5p-4pGM|Ecs{>ES4RfKJMj z2oZUW6#LO&BgJNfpY{GCe-KXyU)2xtmm21U)i{P9+f8q0=FBf)dwU^;S{>i(4=C%uLf8N6yv@ZapqR6+2<8si@^`Wp4#RX9Fo^%nTIE}zccho78!Q- z2F}yq()V!8XtrW4tPajUJdbP~o)UObNMkS`Q<&$ocz@*Ra*;bodUxEzU(W3mzr5+@CJ;o=0Dn3OVWB$x%#N_Zc?PXRpDg4`{45uZv0Z> z2FqT>H*SUY!nLLY);$jbwtjwA+7n*GD<5Nz=Pi%3d(#bEMRF^lqyhxEL|mh?0s^w8R8JHKS!Y+t z?Pj$h3!!j{PwiF-G)`*Fs>_rqT8n}pvHdr*DU zId-r1B6FcAFg))2^!(7MkPCK1c2~${Cxd<*>LXt=mFu`%(ka%Qr1MT-hbk zP>GAyYU;>Tvdk`SM7GpKozGwE)}85eYwp%u!&hIqInb*Exm*sW^@bJ)R(c@y zwLV!ON4&{hoLs?24!xz=9)JAk5)j;2p_S3%%3j`&`T)8=c9aduIO6~P^?c%OWDZD^ zAB6bz!_^SF3S0QbxSp*~QpEYB*ZM_0=Exp5H=ID zc8nkvSI#Q>%}?ETWe-O!-al79}S8wFTkWrF6Rys~wMM)Sbe>F>*?GV{b z-Z8s3612&$sru{V_57)4#r5tnXNU$o$13~LQ)&k`IzO)=WXX;^mQ9>DXO^a=khkKs zJNTxdZJK1K80z+UM|fg+0tV~df_m}p?JxFqhtxK{>Ege@)SfmR6Z>xQ#w#|Hj$|)y zV_r5=_qfJRoe&G?kiotXrJerF0IcFBQpXXyL79;Ee%gF0)cZm4_6JYCBwPRh diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 088115a6ca48cf41a93cb11fc9c20c5998da88f7..4eadb79ebfea34d172e8a68b26ad57faf477c31f 100644 GIT binary patch delta 2971 zcmV;M3uN@#44@Z~BYz8LNklLWo=fo=gfZdpMU@Sw{s2!0g(hy00@8v z5CO;lNccGeSi%nhKn7?Ah~Xy@{Dc5dymA>}A<+9T2O9JZAY`OWLJ}o_oCBsbyr%4B z3O)mdKh6Ni0;pW>+7$vNx*aLbA}NdBHLI2~8v{t7yaPbWyIx!(2%;r`7JxXwj4!g5 z!Iyagr%}$sLGc43IR`|d(bwR<=BHau#|W^nk!ShI^jcf6a{(K zws|)MEPdSrFwaGka3H!9hv7}{8}wiLkAg|xBg5doCS6qM9J<%PTmE{7i0JLHN#|>N(Z5hj(nPm%0=&ZX5jJP4X z4QIbirI#x~N57%Vg&XXc=oroho5aR&0u`;jgj4(48PO6tCJk>dyM>04)i@6HYH&t$ zyC^J^wwU~#U4%;7D`(kNm-7)1%wed{8=|{|On)A)g8bx8`78ZqNkMl!_mh7IeLIM0 zv=OQaVl|=@Xv~R_=tGd6c>Y~78t|gc}n&y5_m^Q(6V0MY1$D5^iEFs2p|Wh?Y^c z2Y;HElJ0J!haO~9GG6|#e!6pA(|xC+;P8w3zna6N3;e(Mu@Qj))e(;Q~d3mL9zeWI_PQWBtH`=*Rj=vwvbf z(XS8qO*CNO)1DG-qT2%s)Wwzj8<}uvzhG7?LD<*n+l6FB5*-#1@PKVD+eC-P24^7A zGA`u}!2{`hnjDFah50IOV0j@%qWR22A@aa!7>N#0LEl({!A|qdBA>;p^I2f{Z-M{f z*h)ad3W*LbvZ%fC5xpY;4D>&Jz<;BC{nhhiYb*4`_1~Z7(Y_<7X4M8YV;{jiY<6u? zefmv~?JwojV`KcVul%9F{Kfh5-~52iJwD6AWk=>GuOB(V?s23#g#Zx0-+c z#Bes^mh4E4E~MbdK*jV{j5jVzdp`#eS%2M8>{Ng4BDt>%sAT-}6aIJS9)G&9M1MGw z`}S+NHJc*|HQv3Md-CrfOOO+Cgz6$shMG$ypfk{gWErb!sFP&@A?k%`bp+=YZYqee zW#%RTHK`7hSo}fF_`i62(>5NcnO+Hr=8YzxVd%~eL@XU0f;Xg)W`CfG>Vh{K%%GAS z^i6CAFe{Z6f;Wg2>rlk?WMH6o&IPKNJM>Mf?W~JVl{1! zG_Lm)is*0dAa|N;+33iSHZp2_P@QYD4tnGfv*sLt9voDE^|mi}>I{mx7$2<;!S)no z)5{FYr$|Jm&+aq1KDXXFUTIsB{(Qg1vRN!ME0}B0Vy>kPxPYcC^nbrzknZgyyL*8U z5?AQqA#HfnIA5M?X`qdc43QS=&>Jw$OzD5-Qj1eN@VN_Fd#4-fi*H8`?}wdT#Nzt7 z{p#Vj*rnBbgGzU{(BUD{nqSSyE~K>Q$5~de@c-@FyG)f$JpzELtH;KaXZFFaE>o@( z5#`7M_2|jsOH7S1Jby&&HM7RWIqm2e%Lt|it*Yoe(JJZ@p@;@xD_ZWzsB-8bvb)!m z>t;oHZa*yBJhn#Z(F1vn!I*JoT01t*vf{C9uMDzXa3PcQpc?8gCzL}El0AKvzAmhJ zv6cXV#q#?z+OaX=G=>G+AGC=UO-&b|nfh889+Gx;m$^yC*nh=2aajiYWV+i#&pFyt zEEs5p_WZc=%){2)SooMR1{delBPT`F%N7#1w@WID-uY|fNxQn`gM(~7qy6rGRjbh1 zvYHznjU0Li_Lw#wSTtK2!wJhWq2Ilq^tT$Xf2@yAdi4{kqYc3Bt%&GU`K?npfqw6P z(w{ULo4lp}<$rATFK9;Y#R=u74@+O(VpfWsnbv+W#+;SQNJDqY-?-b<^4mM~lb-?e zrIv-1Xow=Bb+kQvTMll|W+V}*cBu``P>+wxkM9738UMKCkY%%r)<}LFL}hc}`lHX= zOEy<34g3q{B9?(>=&zm&`X-#A^l_FFovOIWRS0)Ttbbldu_l!j=C(XReD%`JHN4KR zw;f>!N1#r23CrP>7@{61WlCkrX^Lf>F?pZITByox;-9rt>@FAshf4@*M!ft6VC9I` z!H1<q(mn0Q zC0-+lB!41i4#DcQLiWHjBr66%D;pVee3Q1uiAgv(XnJcBp+DZM{_F&o6qH2h_rF9i z=EoQ_PF(fAi(=?CY-_CY-Kd*QruBbamcO>m;OrE6WYdA^pjikw6L!+X-su7CXItvzvNWQbi(8|P-(a^2%7D$4_H z@P*ENw2uL-X!@_;;TYv;s&aP4x=0AA!yo0b+{wKvEt^df!o2 z(~eIVH+b&;lB{y}c{N9HuC7D;s)Aj;r9S;8ed+<&+gBWG_N;xz*e46xOYiV4scEl% z!hgI2kNY}jWu014kB>_`I;5}mlU?1Ux7mZO*%-iFhJ84rzcp$6=Q>~8xz*8O2+l$q zOWcD^#)Xt|!S0~`J)jN#j){dB#+@CkY;#8R3Pw?)( zIOubsF$$0RQiM}zT&IPrW9I^j zUR$j!*PV+^VY-**_o8lC#L$Ouua}EachY+i16K`v@?jfljct#jVs%M~RMN_8~2{CZU+`XM7DO&NYo$ ze8teU$%(h8Py(emtwsW; zyA=P*!Ziq@96E=mtx{XSb%0x(PXLxm!_ zIedN;A7s!c`iN2BUtHspKuMSINm$u%3Du6JQdbFn3}35`%8jC45-iU3e-!2-Z7`n; R5&r-H002ovPDHLkV1fv$#0vlb literal 1626 zcmb7_do&XY9LL9EBIGfztx4t8YnDeBZ43#+6scBHX^62(*=pD!&qi|dX5Me17nRqd z@}4c_HLqI><+YHF?v|^2PN#GK>fAqmk3W9r_dDnJJ-^Q{&c*qNth9zS005A+x3f96 z5y?MHN_@lDN#;iZ0FqkvHfT3;_Wamsg7R^-7mLgEj8j7%T~((YYR`pB-cD&q?vp_H zk-wmw9NE4`2n3=9qzIHsQW3L`i;7g+K@4SsydvTXbaWKK{-<$b*7^sOixM5 z-cVR(>Z*aJX@xT(PZuVOj;5j^#N?&dN~71OBj+m2#=7kb_uh)W^5$do?dWSY+D(tP zm{E567D;L7N7&xZOOnNdo*WpO%-j?Ze$H%sbuhakH>dn<;^I)7em5OJ6Xa!@o07D0NN_wt!-M6 zQ=wm-c@iqSH78)2-wX?X*#gLmFHotaO4Ec7I z=*PvirmZz7Ij*d)m9o;-kfrLJHDPQ9#M>&4g%hQF&Y8(c995yka#Sp)evIlL_hPKm z5|>H3bIgSAOdfKq;f|KlPchI@3rgj(<$Vw$W@6y0rGzprjGD>n^wCkZ5KiJ7he8!g z;?L{E@~**TnlR`n6eoooK+@m}Jnb5D+Q>q2Y0;P8stUI8MO`U6rA}fc5!XFnpO?OQ z7%ugImox72rzEfQGgT=D!EO4U zNN8CKylJ;{_1&YVN-NAarDK^Y?q;M3XMf zD{!LRY4_u!!*1|I5u+A?^mcQ%9C8TL&l;h7=w2S2E#Gcsc*M+@=>ncMTprDxjm5jS z(JRbu%yi|2H)S+@XUV7Ss+ToE* zU?r6B>3gVMK}jdhLk=&ofZ9cP7#@mFn@*U1VK1O;hK-%v@qSd~ zC%DICs+^C%>x-1fV)g6Ooa(?Q1N)&DtCEk!nA2x$!S86^fmp~B1o03RZ#FwqOM+d4 zt1>7Dw^3Z1YW>8L!Iq5L%F)q<6+_z&gEUrDD|C%%f0VS}m8ezq70obKk`8+3=AGTV zqj~8|=U@`Q-D!XmML3HBl!XS23%Z5nn-}Qx_?9J8Yq?BW5;ZAkBIM5W5eex_Cl0Ey zu;(}H^Rw-}7*K>OCHp;RoMfcck^P7uhanSh#7_VxpMPC#?{t$BYWKc=O!}ICL{m-iw zF7p9e0qm>1iMh#3@j#%#3HXCSQH7A)ocx*z##-rJPsIpBOqSvA_XV&w__4aU#S`}t zHpoiE^<%Q>)DEEPfmOxEU4tP$CRs)m>wn}P1Ht`52<5%lZ3XYwoZsqCH*%jEKEXaLXnwCm%B30b7 zMXKOPo~J(O#;wof<)vL6>Vi$p<;rN8HE8y_ z46UByL-e2WgmE#t%YA1|P|``D?==>Le6zr^0q2K;>_by4I9<=T@^r@>UTSU3wVKxU n2yqen$S+j<_9p*ZAd)$`?M(&fhC=Da&j8pTcDAXuIv4Q|fdlfN diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index c7fc4edf4137c18b59e8ee62dc906ff27494c79a..c4363ca4cd933b3f815080b118fd379983b3af79 100644 GIT binary patch literal 4704 zcmV-m5})mfP) zdyHIF9mjv?K4y1zc4yzu?siLS+muphZGl255K1%Fol;n01fXc0>}X6c##0k05k!N z0#x9AC_ov&alc=VCrEw|z#U|OC_oJW0hq<>y#Vt7BZ)f#mkK4zb2CJNdQ)( zk|7gPWN zUt%p(fPTLRlggqXq5y3;od^J@{T-7Xf+mh? zfKvQMy0m0C%M3sdUJ(G+1B{`08yo^6j%r-d1>9&*zXxLj9pETlsQ`%KTr%wUkaB=| zaWvu>r*H)c0h4$U0h&=Uqqv7+6^6xe9?lkB{+XHXp#k*al_uO|^)f1b_QVlEt3m}h z;rB>9pdT-BfGU7#=4KJb#Lab@Jd;wm$Uvxv8NE{;kx%h@ovf`Pfp)Z#4S zt@qtqahS0-y`Da2NEpUrymn8&S00L^9oNno@18Jh%%C#b@NM)Yw#XB4RN>y8Z%G`X z2Q9WzFFeTZi=!DG7+e_+(uYU8D*#Tp`#dg-qZtFV6nCGb7&(iVHZ)OPJCSiy92Wr` z!6P(|OY{J&#lL4=d3e{vu@2w^t_=4%hHrJ3EAxj7;*bGW;h=*UvK2%=Mjye1^UJr> z;*il-!_7~R8OGy&(P1x{qvDVgaZMcW8N#@-1}6_Nj+^T)0@T0^A_A`tQB&BC?qL?ZNqk@*Mq!&F#~k_Yf`-#c-b z2k{|7xT00OZnb%xY^;-S+@w76p5KFVj#HQdl7E4a{Ka8b(sAdE*e}TWbxA+3sq}3q zE-`9n^OC1HA{aaFasZMKNW||ExXcV@o7U!67bH({G$u;ba015|dQROEN1V|z5}aH) ziB(T>j!e!+96Wn5=unIas>!v+Vb0=c;mQaF!|3+Njoidh9+2vNoLrd9b!VHKIGT#P ztV+mGF(;~I`Lwwl#L*ha0;`;Cbmla9}Ttes7x+z zAtjRj#wj`)a6jNIZa=eH3(JY4CGa6$Bqqxb?SiHlqYcw@ImN&z zz{BJGnEgNSU2GgYH zr--8}812Uv5ie;Gu3HU?uD{%=9q_+Zq|j0!eqkNdMD=|=+N;5-)I66aNGwGhB|%Dw z7B<$2KiN)1!^b6D+(dr$minfj(a%C-O#00=5RnK0vZVn^CGDjSAIFm)GZ?rtb?$~_ zaWn2XS^iq zQGo|6IdCbHeH)2Ak~2Y*Urf+m3k`d^iPtDe4iAesV*dAy;`q#PQsWMbI7$PyPvN+Y zMI3&w*BmCW9Gh6g!Pjq&xw6C-HmwP9)S{1!!_m>#l_U<8AH#AIB=T8;9|*~DoXC=y zBbWvUIh!2jO4;)K6UN4qD^m;ka3~HrsEtwdt}%h0Q#Lv6xAVVi+z==sW=1%Dwx@iV9FPI^ciOCIwI*KLmd}N@}CDYaH}oE-ML}6vYutgJThT%JW9v z*%_as>$VjOcX))GQ%LP4EwTz z&}ju6$>p0-!G5jEKrsmtgnjAc*kp!_gOeGFyGJ>hkU~^XlH)Kuz?hQ}$&d%1JUHo) zjF=xT4o*5GqnL~T!IvBsL2(p;6O5BLvT1X0IH^>C#HS5TdW?g&Mqbc|la2w9a_m3L zDLU#wGTLqM${d_jMnFofmCxY-cA5@e3eD0X)uqxcTZGLG`r#q<5AV>i zkj6^YEmrmkZmEk+ONZO!tpYm}evp{MQVVhA-+>A|Z>eG~{0wlzw(UaZ`+ z&(ZNR14;tL5nc}|+1^aBLYMI;9+B_dq1>^T4%#_}7OG06yLUoES>mAyxu~8*B^usL zxJ2<5acMh+a6m_9No=uw86k}^`IlEJzj&RV$!~oTDodn$c9JC>%V#w@7shujku-|p zm`|#eA13t250g7CBbH9%q#-8XyE89+EGT1T+S$ zZ{L+}+X|`r_<2t2i?N!dC<7?f|9nvWcSs!o@r4HF32}tCWrDW1n*fNnZKVP+?T-b0 z+;^pN_kXk7r$TL1y6XyPOP(_*)&FuxeK|P&x$;F?@^8?3H06c5x0{qn!Y!MvHyZZG zgI6g(+e3TP>qDrHN)KF37A;W3P{wmd)fbPuJY-)B7I3Ai;D!iNl1lyF;2$#9Nc?kNT*5 z&rTRjN{%M+66wCHAbD>>(az9b?g*&~nxOJr6UgG~)THj|rqr-MEc+K@e+!K<>)lB5 zkGMCX`lAQKY-cQDk%2`VQ#hZ5ySq@q4-HgHr9$gTG&f0wI)qOo65JMl9^~Kz^7PvQ&f4rSs z)Bps?j#hC)opS$v)@S4ffaLE&ia5@oRl@cmKH1(32~Rk!j*yLY^xghte=8H}-fk)h z;&-+r_O}iw1L~rtGR(7mdPy^t1W*}nz$u(_Z!D57Q?`tCaagScw#s{iRO@Bv9Q<&9;$<59B_`n7~~;!yBr=9frk(cU?sVc9 z1ZV*`?ZJpRY>>f_$WVYGr14Fh^pxi%j!|5M`rIBAhXFF5nPd>hJgSLP46FdiurteC z*Jl7vZ^zvm6o(BmZ}l>ZV;rE`rQvXxp#tFJjQ^RnBG_5{<*+woDPG7j`D|ImF^_3+ zY>UJ2Nk`a=QzC>~{a%#Gqx#at*v%kp(Ma;QRj z5@0nzr&EJ+0PV;S`OKQdBotUvCA7x%URDm2i6{s=TCKNeM~B%`Mtx4lXStQA9cF9t zpa%zFUW1Z_!4pEn8#n3?zTx+{77(vrLu#VdTj;xt<(kF-x|Y9JZsHh6%aoax_jE?l zU+EGolLxu9S^n-e^^fmSzn6CD(iP%O7g#&l(o<9V0s9dJ&Qq0>GV{l27JqQ@xd<*ogv|}|!=Kh)?$r18=-u8qcZIq#a!IrJ*-t=oxpj|1fzsV4 z^g}Fd_Udz;HuCJ$G{{!&g8F(7+1eyrwbJ@UB{5mNVZC_6dQdcd$crYQBp$Jp0I@&; z&P=OM9`;}ixzd;F8VJ@r0YwZfY6R)*c~kR2EV zz)rv=rP_-f`m5dIu6AKpJ2|h)pHYm>>aTU{yF2wf3y!%?HP~w{ZMRR;B#M!x-8-F8 z)EAD!3&%-)8Cg>eE6R;hf9}sCM5AHLqzlvZ z*5F2F#P7lQP6W3kGXB6&NF;;w-|xMN)+W78B7=fg(`iFqupoOGZ#7X42mXV{TUSV7&y_j=<&;2T&Y~ z%v-A0mE$Kdi+@k$SNX~@s&U^z9Znwcksyo1%2c9w%~{+ZHsEAFhttWdJFAb329Syq zm>I~q_$DBT+Z|S@jT}_&mZ4J2Rq9{!m`4Ae0+zj zE<&C5BJp2#fPc@wsZ96h;{%jhLCstup3IL%-jHI;v@CB#UY8y`>KSkULG{lBDeHKp zp&)*eV6j>TrtD!>jYM>bhLeVQPq|wFTF}XS`~m(V5isL*DciLy^pdTn1Ap24flhC+ zeLlequ}**3*{_cy$AZG5=49oq*Oxlyc1+YH>VZuV9?fI+vF?7prZqI*l1}qJt^Y5& z4^?fYYWf6n=<%ETCvI8wr`hXd+0vk3g*p(a!B$bcSsc>wD#ibI2&KU78aCt+nG-{Y zL>@+x0)pKeXUmm8TP#a9i~F~4x((Nx-QADm6Zr+r(-VbpV%h~}@Rv36QQs|AIuh@F zA1{;2sN=k@1EmehY^!Ze7G4Aq191;vQYVuayQ`zQMKiTI&7ppgu|Fn&j&joAeo3!4 z*anqamf*$p7A3dN!i=S!q(%T$^6<-CMy7i=9ic99MKsY$l67uoASWs?e<1vgsc3k< zxMf1-_G$Yzn+SJ(rR*YpC83F9N$=$q&xW2ACAmEixcqO#rZ!!6(#}JC;+SzhPj)w~ zdZw!QT}@o>+VVenV{tCDCVvh6Bumgx)ZNRQ5GV$P2doxZqZ_)Ix4Szc7c~EfrofK% zZOgGg0>{S-p(?8}g0kpvXXo36upM{jo&L6&u70fl zQLJU_2?aYbZ=^09CfDdXSSMYs)$>jc$tt1uAE+R5&|Sqd4t*am`?Ii;yvaVf)c4;%Np7>ICD+9gQX`3pbh&{*vn;HWZE_|Kzpv@ zgb%{9T*PS@>z^9oZ;z%5$IYb*WZeI0@Xt&9*F2krY|W@zh6xy7Ru%QA@*I7IaVERR zKXLVY4Oe8t9EEg-bruIrNa`2A`9;cobLQw%I>}dtcq={kS%W%Z(}bPk+-olOz`^Jg zVMyG?Zk=<{qz~vK?TS^dO-_qh%BAYkgY~;daA1n3k8!2RA0uZ00NWoX^pRr(m^s`ZORdo4nD-F{fqx zea%?Jx;W6Hpb7lqM-e-3gwtZYp`v^T7kd^2cusv4|YM&>*kY9ngr0$pUg`!25N1DJcQ#7}!wT(8m2DI(nOp z0n7HEmYK!B3;^@;;0O-f@=R8`CX&R+-F>1cL3Lg==W(A0_{?px)+=+5?5EyNYROW% z@nYw04|caX0yZa5E0M63zr6vNQOJ6AG|^#Vr=?D{H(YZ^Go_rrnviHMxuK`kGWxkS z1!Vyf{iJlSYPb$^{H;g-UsXG5T}Km7^bns_M+0kw@HYNa9&)`V=uX>t>Wq0KiOx>0 zv#iKt+^$IWNN&0+Lc9}LkwQzFNDVo^;5b_nR{sLwVX>OK*BdpxIX@U#@?))Wb266^ zSVYU&u64IqppMIr1)H+OYFplQu7A3Eork25=ch=i-;Ty6$DfqxLb-SmDw&}>VbJKD zPNBdBt}nw1*#vYuH{!k9WQ9V2)2o)J91+tv|Iv#`<(#)3{Ta(A6N(2?Bm#V(f~U&@eN(58z)A z4vX8CGW2X*UQq}VX|zwuJ5BhV3npZv zG~_e^yD>cN{iXrTW2Kc!rW_$(2zSvqI*nFKLxK{{IIL>PdLo>!LIA8`gA`Y-{ zevnH%L+;Y4MXHsj;$+Vj2KT}|z%SE|+8&9%nq>cUUcKN6;Fp=r=8n zT`8|yKqU_ackw0!6L)(EWF|*1Z+7b4%*0sl3G$4eVu7A)D`-T!V=T4R645+WBywa_ zVezuTs1|^c7d~sl^x)a;y+Aw~O}yrZVdv;h50#nz)u&>X0vRLHW`#xyzDhvGM3kRZ zX5l=eU^h)G;B}YS{<Efdu!F=|=)i+Ol#eBpSaA0IGgKlA>;WIf{p?UCSN8;>^}g zR4j&#o1sURLwevz3!g{xz+YmGwvR~8f!4Gc@cRCU5;GGc&VpNE@@Z}2!uyHWA^lMw z8}dD>CXWqIVwQb!BqQ3&4kvBnx09TI94L06}1#cclL|dN*3t*kCNb5k?=oPW}_GDTW21#xu^D& O8=sZA9h3n6GwvTnSJAHk diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 4c27a15d4df3cb7066744c76fad94695e4591aa9..b61e41d58637bb2a338e67cae4b96748b4872e00 100644 GIT binary patch delta 1677 zcmV;826FkN2b>L%BYy^CNkl0;+&N6m;xa9j0B)~ zH1Pqhxfe`ZO+9!N09Akr{yb6k=|#q6yQrr-k%>_p1P)ND8zNO$_;Zj&deyosp%8)C z5+zOVxCW=TBtRa|!-{AqVvgigbW^H_Bg_oxmBNgkIA>8qM_T43sD4un) z6KF_Ss0jJ?veS-O7GQqWm8vxavRVspztvMMiLL2AR!g|wMkf7M;gh#4@N_dXb{A zu>eFuJ_%+?XU!(sg4yQFD~M&{^Z;YREI_V#Fj(_{vbzn2A1b)iFqbbh63ggxnn-_+ z3>3tfY0taHv;Fe+g1ojuhQgtOX1oq{#ApNK=wJs&$hJP~IkZ6CJ-($9do9!t&hI8+4|jnjoc|)zlAZ&(zE|^qMNPzMJ4@Zpf*-ss+XP z&B~!u^6CRU{lICN>0C{kJOnETp$qy{=jhnO2M#R8MT(Tm$0k_9_m&nIz~Zy@{MaSA zV}HH4(CpgVj4t2fKmQ}I%*55UEs7!L(_j!|(_+kfF$Ps(eR(dfhSC3ETAZn_gVeZH z+ht&O2=QSYv>0`)lSn_T#2`*{-$~f>nAU(w_aH9MgA5iDpT5A47x@ntJs+TNZlQa( zR6iS&m+t9r-X5`d7GtE4hJ2E~Jp_#Kqkk8g?y|jGjd!=(k3Eu(MHOsc(ZXf4-n|t7 zaqc$%`aC-@%=SG63Ch2(jT-@=`-hBuPlA-<@T3^OskvdOZ55#?rt}lD-`a^Hkw8a> z+1@QAQ;R{c{X^uH$L+g^C;3l*>u=S`>Mp=0 zo3@~DJV6ME$uMPYB${Qdk(lLX@Tr3zzihm`(RgtKGcA7fg1j4aSxyQm`hV6|x@S-d z$$tzxYbTrYb5@x9MUR|`Adu5@{Ht^9gAuxWknSFoWh-ddFk^33OOWEoRJhUh)F7{U z%xun?2nhb$RXI~;?`$Un1(H^nA?5U(b!1BX=`+Q(M#4L0_c(bN3n|Xts+_$A zeHpSY6VwHLP?7(JO>v?gLVxJM=e;hVvWc)eop(yA+t6V^TBUvY3rtvLTRjv2{A1)T zHDMm0w^iXzBHyNC?NodQWzy`LWM@YLTaJ~30Jh6n)Rem;D~_UG1rVZOMtRXqQ|hdj zoJ8XuOzi{DHVgQ+3IO^xiq()ujR@?Asw^})vdZDmX-xoZg9QE*y?=A{74*FOd5{_Z z8pm*bSM{((sL(*g&3f#?lQ=@IOW&}Bok%0ZJe<#XlI|B}o*!R^Knj()>XDeOgvh}h z$4(kuu|{M*jn< XIv-B$<9Mb30000OAWDLT?8( zRo5uy9IV7;S?-ZL^t^Xm2$yC#hgZ?tLHE!tUDY*xuTPqTfRMuDgu)IYLTbE_BJ7Nr zhDtG3HivIq;(wiKqu$|!5-}(2>?#wt-^kW(Rm~_Bt0`b>7U0uQ)D%r95tq+pRSPNA z`A5ZoqyHC^O;0*89(Kk|L#bH*2ob?xEaAkVSdxj8qf*Q}_XL~YQTzCP#+1mGF8Ami zdOe`98KqY&CX%rF$;55fA7W=L3B=^b06#-Ij|nu2iGNG&RGv#6eG@~}(JQWX%FfUF zs%sRJEnSm6jDah?#)!VscE$|EikXHLGYu=2AgpM%)38=teJL(Jk6(C43ENWSHZ;>q zEw^cHS1cn1(jBL+1UQNd&&F5XS;}gQ{fFkvR89LTU1GAeTbz#Yy%oH+7*n`+xqU2v z;MA9~t$#)So~tn;AAQi&fX=;QQxpMww{1&dMribZU<&+jS+D@u`{29(7Ske zO;JMRd&>ESg3+smzpUD{S_QExitC*qiSMiM&$I!2tJ4vY;Q3qERm+ z_@+Xc)CnQqTnLEt_@k!4)ctjuiI^Sc+l%?Sd=k>T+@Y!; zWq)Gyen^~ZfM1Tkc;_PFJD%H`F)6>q=Q+)>ymKfB@lD zzjywre5i6mtaw$Ft=;}x$yuqGlMZZpIlaTwH=?!;ZOi?@cNz%d2UEYn-p5SCikXHL lGYuD%PDHLkV1gl z`)?f8701t=xx4GN*Iuu8oy5+=P8=Qyi3<%-sES%ZP?3OC38@4jwNmIK0!pRwLw~5$ zs(%5h+Fz>jlv+VRNTm%`P!d8a(efxQNg#xf1jh+>OyaCJe(ZX8W^OfScGhcqA2Yl2 zxHmf=t(Pw4}g2AJ`o37!kq1&}~Hqj`O2 zCmF}D30&lPyO{G(ux?aq0^qE7_jH#uK31WfNPBcoPXv>2TA})B`d#D#TKE_)(%qlm zx-VD)C&!$vW*o*C%;IQ5zH;|gZVDCx=tW!Mtb2l6GPsWH!+Ec3Z`n1$y3p}D=GNso zMaB6@0-c}kjB4%(mO^WO(UofpKpL$}pE;Ry6sHBF0Bdm8&W9t%HwviM8XVpnb&fbH zSO|BjMx4B)*qp~dNQaZYf9C}2!Cp1t*!8XwF5`HX0=VSN^&AqcANxLs-di5X;YxHp zz!wf&%n8Bzv17IPpjDeW9M|JzTW*cgv|!!X>jSmwqM!vDY<7%qPOt>75Pjco6a|JG zNdrxJQ=1U1ANy9ImMseCP>nUdHPfhI{pd7P8hmw2!_X`>x~y(UumpZU0<{`O#g$n@ zbA9`Qg>dgNn87#66yknLgSBQ_6D)+gkwOezu`q;-Z;BCs7zdHEbt&LnKWb^8+S>a` zxw01*us{Z2ouwcp3xXy5k56t1Mlp`R-YlV>U=luBB#Op&rs^BUdVd|I31?v)H;dND!#~v2KsbeLo3l?`hh_!JDp^w}^wyo6O8&~%n zubl5(1-gABxoa&ulTn{N!sfZ$cuiv(YL$_wx?nK}_Oo3=zI#A=@LEC$Ss#aqS@n=r zcbRlUO8&{P9y>mifSjs4$EzPi!Ys6Wg`ZRrEXMc3Yp{zDf*Gv@*2m$H)z^{hlNjl* z_fc5g>1rFB;v%}1YzW3g@In3yGG= zdbLt%O0-O{SkS$=VH~a9;$Z4!f_1fCju6i*Td~lk-|;$?Z&5BUjfq<9&5jlvUsNuFK0?dSP9c_P+@>?nh0BUR2j0z&8Z6MjD()Po5UlGG3n(Kg+0eT zxGBANgY?*q1PIHkgwUusZ7CwFdNRv6}O>Lu(lxQm3#8ILsk?l>Rt6X zi;%3s7%T2lGCLsvPNDj7)7s$P7z)PC@~eb)@-oe-5Y?N;Og+GH;iU;ZGn(pts$c_56!JiIKNWxDYd~crpnTfHfXXO9c{m#) zz$zNhAl*SE2v8v-+Hwny8KT;8gHDybTU#&Wgb+|Yd08~r;d5-qMiQk=(X@ZtIJiao z&p69$)*}y8O|u-4&k?$7inArN6|{3#3Qrv*L*49jhK`Q3;B>*yvi$m0_TeI52C7NqC4(%v$pM4SB7tz;mszI0su$LR*HAm1}cAKAh# z%od)0pG^q0y+i~Y!{kT5Cf$$%AoRyKLT6ar^QoF+O zL?T8}2zlowkcje9{kjlz`;fG2Ye_A=!r*Hs-EOUupijz7H_pb+y)$V8XfsN9x;Zi9HU`%`FsB*xgyclO#5Ny`=p-BFs9y&{8B0u)^1r3D2YQ1nw`P=8-wzp{_#o#vaW{D+~w?{#e;Y zxA!mnhhYiD>RSe8F`Tk00&P6z&eleGYlL-#=&r5Bc_SgxJ%gp6>Sedf)B*q3%5ea|$il z#^o@kK66BQqY<|V7laoArOE_Tu-}Qf+oHZTLKw(T+ywH{(r@~>_3TlB)mmJ)txT{q zo=IvW7sM4vEG$2~ne?=&dr#X+q$AC zlXu^w_pKK{R^B{gy9K>bNSZ#zRUP_a68;`9ym*PcSTF_4t@v#NnnXQ3s=oWBevWfN z5N}%zl1tBuT{cUcuX?VINbq^iR0Axlp1Vx2G{7o=3!b$opgO4v4&F-ZupnrF6CxH* zHyYP#8gYGoSS!$7dD33R(-EXc_)Z=t?aQbl*fbwIWW;al?3drCy=u~4bzfqKPlC-A zTgpI>lkxukQhS({#&N>@S%h^}o%dB2?7TnIMK(4=HYSYoEv4a=>Orr|pODSTg^oSy zhwrCmN&1@J`gtE5AL>UF8(XUFaR<>9W6EXm*9Q2NfFx}=@8Y+Fsi(r8S* zg}pllyKjQ9Y#drcf4H^qyAM3OJALpvSRE_21eo@Y=f3bHz-XM# z*uk^pzT(GRx^rzPGhcY|s0ZV}eD4N5R;P4G&*>@cV{dy{sQw;uJ;CO&g!8PYh3%EU zd=mObAL%hXutfUaHQ}u(eYfR<;(~LwY+1e}LJ1E^+7X49B)j#~#@q5)_Wrbs9 zmtbMX`Kt;?XSxcT$*WI)AphoD#5{FV7n(MFbdcNo+Dd|dm& z+tXWDS#nlb3lX^D^|DMob`k#g5&h|nOODDWoe{T}W(*kHdq#QP+o0ffv|$#^m^Hz& zcywl^gS*{B-Tws}o1stLLK1D>te_cVkWj;Z@Sj|lFtdb*V5Ae@U{8%ASRoquva zy>CeR_Byhr)75*ibWS^bPJR6(OM4zWma=gcn4PGpNk4)42O{^_Tj|;nOufl^LUj{WJ!TM}5otVwwoQuZM49pDf!X*4pGUJ6L+CWFQrJPQgP2)(G#IG%W=EMqI z`8YjBxRB$lqF=uouYJkF?_Yhi|WQB(x-3^GT_)?D-0{p47zsGvYS-|vZ&TN zTq?H0>@kF;GbY?!lDc2^VHCSsFHT8B&l9Xc5+lsqKf>ITIvrVj^r6?$k5F27NuaOi zoJV)_u&~NF8jTb_zd9x(QH-+9cp6XUZ6%xnN)^J7q$IK{D~|6)@j5EWr_x0Qd{A_T zDx&36C(T4GpBy^=)tXYwO}v%3J9C-)eYF+X!YN#3`UeNa!3?Wnq>QxjH@tPC^$zh; zDV_tt42-O51#J$Pea=Uc=(Lq^e(O`jVnw(S%pg}5K-Y`88sp$e68`7;71{Y^9XVHKWa)BeYPC1Z~y=R07*qoM6N<$g6UZeYXATM literal 2034 zcmbtVdpy$%8=vyd?JQk2ytP6)mFtS+l4MA%mARcpesY^buDNtJTWyg`@=Auv7&5ue z-IT=dH1m`dL z>hs^%##64O)&v@7ndCmvcG@4MV;cV9bvy$)3Dee=G}=a9?Y&Q0OQn4d~XWRY= z(%cUTBi9VW>ROH6iRU=DF|px291f$JaWJ}%DTox~H@RaGsPQa9lLc=qQhL?T{JqoWz8dkcKW>B8=v*@(M*%YOf zciC~pp_S?lx=MMnR(CTqC47YNiM3&0ov>P#>@vtzJ^+&OUUS)H9Q@{nd}33nDNCVq zKgvKyB%8b+#Sn-8Vf-n5s9wco^&sx+?6?JllQk)%qAlq7{GG$5+?hZWs*PdlDazpF zYuJEh`>k^<*Uj!&v3ag#Rh2fNdgig(`A`D2RJ7Nz;s?Ys#2*$uTC;7pOuKqejk1v8 zI4Mz+N!iIpO;I0$4i)ZJb3s}jvTJpABHQ+$4Q59FXB%|D#7{w9-~2mkJB8GXYnBSZ z{{o6jTLZ~iwWHrSS5sDIQUWF-LuhAd4sLf_daR_DbGL{(m#kR{2sr;Q2`A)= zDX!T9f-J?$4`vy>iC3A2FqR#3k^P*dV~O6&g}JiKc9*KcrW=hCS5@v>sK{V9@?>0 zT3BrV+LBh#%j$+j;WSMZsZHz6Mbq8L(w1=l@NEtY;;Ppc98#BZPqgxYXu}_a`Jm&G zwX$GjJGr?zn%zuS<+%0zGeSh$F7hJVvzvj%?Meb)&kfhYv5|!Rt{=`JMp@u#I?3+Z z%hwgV(Bj4kx*O6*=1h&{d;@N}Sy=8?EvqC5K#67U43B_KdHI48IlG4Ck2Z~tHv+H3 zl)v?Y2r!noHfg)KabF9@qwRccfaKBk`WxFnivsc&%DZMyv2_d`{3A0Y?`qbyeRDai z@#xk40iE%QhER}iv&2csX;!L|eXUkC^ji8RM>rd-t0C^D-mX=_G2R6SR9QKDVa(z_ z#m>A*=k)L4^O0dcR=+-|gAa}185+TJEJN@CTtd({8BWL2_mc$|Wpr?#prC9y&SlSF zm4kaI$&@r8Hj2q?R8=XShov=>cU_YeG)p}Ft`g%srAhbK2`5S^KQ>(@&VV1w^L_5& zF-HAn2Fn%dg9*xaBaja814LS5Z(kfIe;JB(_6qFX2&wDcC^#AmpCANL4adi!tRo8N z23?RA!l9DQyY9<7zM~+Cr|cBNr$`q7+Hl^3$0zrc7Vd-hT0|JcMju*|RVk*`=)^FM z-}3HiJtP7Grm%G*Z##Fynp$~wg?ikI!N}iRq32uxJbUu*C%L3v4u$cj*Y5ptOD+L@ zaxNDbe5v6GEaH3eXsUf4B9Q{`02K0IqTXdVL$mamw)vHu;HHq7o(zmM&i6qK(13Fv zy@?dT$DC7M5t4c@X&IXqr%Dl?(o(b7Nhrb2ck9q`+g(yU?ATYB0WO&SYerR8dgnYZ?ThSaW z3HPq#mYmAjs+AX-&r|Ce*lSJtR_|ksOQIb3y^{-TG$HfX^X=Y#YC}U`I8|tv$J1}S z!=x z$D~sOZPwpbKR`_8+S`Q_uppXbJk$Xot~xE*(Ib1%3}IL}YU>(Lk4p{ojuE>o4p9@o z@O_2L3m1Y6w4c5LxEuV;)AWV~s7%a6c=T>uzdu)xpEF1n;j^4!c`Rh{_A8($b(x?L<`j!}Jbb%e; hYX3EH`;S6O^aIY=sNo6d;Vmr(+1p$~RGtS&zX8ty?iT<6 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 5d82aba8ef1da95c657771ca3a3b65a2b346b3b8..b18d03d111298ecdf20a78db7974452220ea650d 100644 GIT binary patch delta 1807 zcmV+q2k`in2%Qd)BYy_uNkl2-ES0C6hCL?&TeOSyR8Kah+2q7tR{v?NGJ+u z^gr-H6JC5YMuKmsU_?;BV2~#sjBmbZOnfjQF=8U|gO8$sTI3^<5Ba8rw7YG$-I=-9 z%$Z%fWoLHh7^A;PY;}M_qMIcknW5s7bX`-3DqGKt$79|m7DNoHwsBl{l>_O{wr8?7GdaNoJ_Ypl=6ijX~rDx%3;yGm1(ZJ%}APbuh7;%({M-y z%_crWLz13kw=6>sHVI)v$qgoHRHdx-;gH3q16}0+l7BjB>6rZb;DRjZD6YvCF}te& zey(Kz$&4C*&1A;j7s1f1*S-})GFDK` zku>}pW7c5O$7t(Frdw>%kBTxWZ6lHyIg8X<;Ib~5bi~CG|8E~`$Idh#7?N3Jfks@7 zYECDq$$wrwb*7CS>%bA^SNFqs-E_n>!^#^Dh${*grZptn6d0<>Ku6FuXk$Jk z+jPtr?rPXHB$qoj&E2aZnF{OF&kkr)AFCwIS*|zr7@ZoKRg#Xh5TLelkr*5gQkurV zMLB#p&yQY{QcPd4K&kF(zB%-DmKDoVEQI{@V=+wek4b z9?6_ECl2!|L=di=Ac*KJbEH67Njfw^@>3z%;)|&ypfN}!XRj026hw`=t15{-$;H&Y zTLF`<+%Q*irerf!J5upPrOg zm47NYFo`>4EeqBj|iXMRp;amLz48Pyy zU4}MyKJMoyZz?GsD%;Q1kfbv3lQ*p$huNl8T2F_uX_XhkV{fj+`5CtlcOP|*T~}B< zQBzLR<$29$R!$K^d)EMtx|Z#?bn|J(lT_rw*d^ckv8yfU2mFs)*2eFF3vnyh zZ04J8^t~gUs45<+bgCrB@70uIw@dtR+$bLN zNh7QyX;DkWGZq2Xo_=H3bAQ;KF?OyKgC#K<^oY;BK+|yjAw)MA`^hnwj5}sBJ~^R& z>&ar+wC8$=e+t&7AK2?Fu(u1lGuWMpaIFyH(ulq9xHubkS1TaBBVf@y5#P}hmy6ch z2XSd9_Rg!M3x)t%T%5a;Xeg#>&hXn12&jZbG8=brx>PR=iwkOl|9{h<5tRV33vTHN zGFnM)ty<_*)!-&=XimkJPqg>?&}_soL?Ej~w-Bw`pL(by>kz_FB4a4EUVmy$tn@Cz zsb7(_%aET98y{)v#&FUWI_^rjB60!<$>BXG8@eV@g3F!)bx?n*jMg@SHwed~r8`E9 zpbLNz>e3GSFvDo4#($0OipAO{u!v!<=5burNYNmSo8_2bFU0c_Go*6r5`s-KoMNH@ zl6b?%8~vUVpuA0M?`;d-3kMQQx};sLvFgpTD%PDHLkV1jBocZvW2 delta 1024 zcmV+b1poV;4wVRyBYy+jNkl&k9KgTd_ulL--SXOP=@P1~K?8z@#t@Sh za5W~zf53Pk9y}RLOnS2IN>B)ndn1Bb9#%&{lLTME+6bUWbEwKH$ z%f5N@HM8|#*e$W!nMWArbJ$JZ?9BUq^4@&kcV?dlj{)Ap4u2pDtfVPeNmHDv5p!V=kezHCFOrVI@_7 z&uxHWKohgITc7gLkBiZ~))+KPwyt9XFGC>8Z+{CVet#=-*|Aq4?gsCjrYVj2R<9&B zFmM3kE-(kG6Ms$Ig@lWMG7ss*&Ph{HFX`-hf~FSe&)S*h!QhkYvBMX`lWId!EQwoN zyuKb-3d(PNT}OA;zX#LF;KLztVOn8Ss;^`tLj+3JJ8YuTQFR`&bJ7%4&q=C$(b`5? zWA`c)ONwg+x-nN@YL(q{lTKHf{zPZimN@G_#=pTZ@ z{Tr{CN`J7!8SM12v*{6*3ez*x^%&hlAr+wUD(;g`g$sh5n}Im zW9vF9acQV@_pv5XWY_TGG!5kB^=xaX96(q$9Dm4(OM3s$T1Kg)i}-Z2+@YyjJc>*C zAp0ZxU=LPyR;6J!C&p?SBH%MAx<0FLk*QMBedAeg@ALf2(eSG))vYu(!#~P-2X_Mz z9~iD}2;jWF*?V`V_~t7A;-ad+lwL_cF0Vr>u~NGj%?G__fD_#*uJjvBB@h9c;tJ!6 zx_{!K>7|IzXv?~KQN8`G0F{!sC<|04oebA9$i}4nnk%b|OR1z7%e$~0cW%T@jdZJ? z7acH_aJt(8NQ|qjfs{&)=4qmUo158@3_tnvY9I8tCB=GQ#0ExxEsC?Z6b7brP7(ws z&a#hR!L|l3n}NbTC|14JvJ!C@S`vszMR^{4@{`)_X2pqzZp;PyfAIFbfIHgc12eUn zzC_~p+x+uiX`-MuDh=6naz5xElCL;yYsK}k|J$v|#pOH7^_J1~oTQucbaP(Q;H$+J u1EydlO~Fc0ssI2m!P+H000jzNkl zTWlQF8OOgfmtA}9^>yvwYvKeP5@;xdT$DUZlQI zwJ&{XDzXob#RUeBXB@;GF>g3Xle92G9Uh00lq*5CK*I*6=5Q--ZFA_;n7T6MqE& zGJu5d@Tt##N1aH1Ux0mD%#XeUU>#rvm8*(^k5%*A<-*?*fIbv^6(9-F29R*!%H4z$ zA-nLiUdPXR2~`9GwRc4#cKiVqpDTq+!2eZp@cLpTslTeCb_BV!u3BdBY<>I+=NKOy@}E9R1K zm*5x^_|=%CUiIe^{17sRol=6&R#<^$eC$AngM(8eYj`W91TcitGM{c+FSv*%_jVjO z)83udLm_)`MnB=*34LP)A1&wzobl+Ko(S2DPOsU9>q{$RShm~Hsq21E>%NdZ=;iY2 z#$=8eyom<@rrbS`n?g!BwTOE`^0P1GAQM4j zjZ6Yy7r=RYFS03QEB4?V`B7U#;vK^v5$&JTbcW4f*-}i}o{-oPQba46BQuqd(D)eH z*`X(Qd}Vj$p}*5PRwMq4SVucHVq^W%j*#0h0F@ueNIQ(QXTNC;3;TN2SNVy(T*1LI zW7Y7nEM$zsiGPy_gk+BifGqfYJ$6X|^jmE_)`V=ranb3u(17a9VH2_tJ!(zJ_AMMe zb^kp7+iLTr5AIgt@j_mHzkB$t{M_$$b z$9DfSQz`YJypV*4iQ~?8Lq+|1V-XmL!UfBL+#O*u+L=8T35<3D<3bvVL8NqnlQKfe z9CWIH-c$;d=zpC4ABc8*H_Z}$DqOLEDM6)jyUGZ84Kt-t0*a8((YDF?UrNZnV8xMP z=1MzYO9@E=4*=VgQfHHp?FBRej-O&rWTL!roOX z_yv2h5W=pG+@ziC?gU8yMXUT0$GD-Jn2{o6Zwy!yat7C&=d<7C3CX4tpheEPUnud^ z5tDy1BCzKJ$`mxbJRyU*BX@`!hj~JB67m_a!4lf1Xx5lQb_Z+79Fp4D_ad)FvSeKc zSjDvbOd*4Ql~c@hbW=#N!gB){CW(3D>xPgmwP!rLPAWfi!^hsA)qzLL7gA#QIxozN z)SS~zu6XAS0D$ug(AR2YkI)H*R}xxu^A&|;RYhIM*QeA!eMFA*!{mzk><3<+TKUcU zqDJAmPWsPi&Ad@B)*p+ zXE%Jrgi^ASwmv1+OSPm_OdLQ~dA1ZZ&bwCn$)a}rym0p}pj114PP=R;txnODU%w;W zzaPQ^eRoDX&0u(hW%Q1fy*M3PD8GGAcNb6!b`t6gv=g7EUz>uE07Ib%qPRCEK602uLo}YuJa&T4a=5A&)9WXpkbG9V623G-hTHU` zXh{6wVfw3oYc_XGk`4_BKR8HmhaR%6MZD)a<#+G9b-C_AjBvkoRXg&gTZLFM3q3q2ls@RAIG?7$( zbV`f!>8muWn3+O`{Joso(dJW`Cr`^iyB&HXW*;kv58gsoGjyeRz!3Mu#QbrQQo58@ z#$VU2@Uz)#5oRQ)DI}l6yn!~imU--i^wby(v=UQG>H7z?zn;?rmyKSRQek&D`Sv~{ zo0Bc0M}6uwZJJYK50Q%gPgvR#jajtWwal@T^7t4GnSE?n62EqR?g@kta_5fxm(awT za_p2A=e8}PA7E3R9bmD>r!DAwDs$|VG=7^f)CSe+Nb2Jv?OLkjuEm0O!#HbRm^_;&f>Iwp<@u{DeeOdHkf7;L}De zBG0lZEc4mLkdP!S2}cHiQuXyITkdQ+zm`6BGBkda4o8dfShGE;e{0Ta#kN>Wk1E}_ zPfx)FfN$qfkBZJSe|k%CN-T~%RKt3aL;w1 zQ2OF|t=3*qU-itrr?++0%hFbLC!TfmrnAv`)DK-wtH1Z5{F&=)vKclBInTFeU`x`Md*}2>A0k=;EpCK z#kZx~wo2PxRSfl*1HbZ_=}Lw!rHv1f01do=R3-pp76S7WAu5W8`t?PJGcs@F) zFJBaH-bOw@Xc+%`H+yrh{MlYQwL&i~YIS5_m4k#tp+8DSI`jypoRLETrk2%b|Ks@S zsv}^es)hU3nvjb)*!nTdXMXXP9BUSKcV%N%jemntG8pwJSd3CgtYw}&P3tKpS~J5| zXMmO?%|8X`FX5|o9YxC=J1O0Ni};1%sW>>H^nma_$Gj884T2p>x z3ST!+$%h+I3oU3))-*M!HPLki9e`{E!8Qo3*r`s|`06?`mgmue^~t5)XoOM7T2-t& z^*oJk*d|Lpq=2$SusvL<>pkkOArMC`{qV##2q9ehuTMpXib2GK@v>H0u?^I%qH|R} zGT1o`*44{zIa=znZi;ZOJj)WUb_Koyi$rjy;oN=iXq}yPOxg=lN2=0^vwlaHlAJD> zltBx2C;AG4aP^YxK&!%O+t7u|R|>!dGtwv68YZ7Gek|^U3cFUqEP>`vta{YYR-psy z;<{!Gx><{E4O{1ST;@($cz&ZIV4zIgT^E?kMFlgsuGWLr?AG+Kw_}fLb8)c-JgE+u z!1HwAExC0pAQ@+hibs*8UX}S&aIrK7&$P6RM<0g9IN_IK zk#6iuJ@;_=QF!+<+Hd_BX~0aOEe8nWLeQE&zAc|BD68n#AZQDS{6~$n;+ny%pZBG& zrLZafh=kqok(a5*jcdWDcW63TQ7F zt8@$&;2OknVU9VE6-z=IXgJu+;t~Nd!{+^MqXc^k2@hCZaAi-(9Jx54W)zj;IGXfu z4%&w2YQiSS{3dV=3z=gr&r8{a4;j@>Ic*4?0txZoMw`hlw;(SL!M&BGv@S+%5hGc6_SQno5OO- z!YYT#p%OI=#nh9qG-6wOYz|NFxBBvac;65AeP7r8>3{wIzw5eh?3vT9AfPG`004m8 zPQX0B&zt`#De>>UyrvTk07#j;!JNF4Z*%6($EZgs^sS?MD)@uNX6xCOXv|5J2R!pS zLm&R*y7*HEPW&pZdtHLy_P?#Mwh@sNtZ_-l)jo&cBig6y3__`36Q7NO*cf9$ ztbJShqAk)fj65A~=zSX;kFh*LY1_`6o(quYS2u4m#GiY*rG zN`F=cm2a?`Xgth(0`u%mcgrBm&4QSv{vko~lV@$r7j`(;uND24_XLDZj6~1J7N+h3 zX_jSMtCjeK^4?@pl9H8Q>iAw;RrWqt{+UPkOp_RJZeS;{_WrH})oh6FNl%aNe#vaf zTFjq$SN7x;O&jlW(g1Lc#{+=@5f({xiu2YBTzhctjm4jR@|4o%9EOBN9Sl@W3>IFA z)!TF4%-7`}#j1g3DdY`}YK^8KQL>{6CumJ>7*v5kvxR2JI8(C4UhnfoWIPPwJQ@@A zjL|Q{N4hfT>eG#=;pAbd^*GV(W7qJm|t7m5m>w&aBx(zaaUVbAWX*lqjLj#=vMQ5gpZJ+f66vRBd8`@`9PB&EPV#lX5Ll$ zM|vW>nqKlW^PD^+U_L|2Z<;sOFoDBm3o32FIUPL-4ucH8Q-)^PW1ZvHe^-eHUH$E@ zJ*K#G=5p8;=uqk@Oni}OV+IQ9?8{arkj)=t0q;HL)I8tjzBX6t)CxLt+r*%f8Ag(q z|GcU=>B-iz!{#x25RMC)K|+!Dw_gw}qQxOL9I7?Cx)(X&XSoNo>e@y?A=Il*nsXEL4^Ny*&o+Z*}F$PfHhF2RQn-o+@hvCkXkNHimJHj zmRd%f4s2U+IkpVfnY4(5aGl5l`xqawW2A4@t<`I1?v*CBlX$Emin9EQ?&xlz0tw0H z@!nxH3_o^s7pNB7iuURE!{7XV!q}{h9xyz~67#a~52TO0!*Tcz+Fl1sk_qkxQ^!5_TtzV@w9bfh&%Hq{*|n=8gf&7gD`Q)2E4C{g`( zH>WNv>3-*w3}DhI&fSuz^8AAnkl27%G_`q@u?kqHZk!I{*L6;mPxalX!^w?v0z=$X z_JH5LzAkqYSV37vzrgUJZLa%aBj7dS4^$gS;IkYD9upD=b1z8c1k*;nhc78Do{lV* zXe4`Q9P2I|*wC`_H5Tf@m4%7_@&XT$FPQKBq=&C+@#64>v#)ApB_LC#f8uMg`8-ia zL=N=875lXDw{Mnr>*)bs1xxkHQ!%af^Yni=HXU>d#vW;s&E{3nlA52Y>ppuyJq)pF zk;LSuuCz^d#l3->Z$8UhgnmBg$7Y+H^ZUFOR*#HnFS$KsDv35xSdkH&pMRUr6yHH%PB_`_b?Xbke|E6fe8nbiJNuc2qUCQSdb#Z$(aTd}ideaS> z#`7d^cjI?19ra1GLmv^f0M{;IYDRbb4{t@^YSz;(kgqH!ic>?RK}Os28oO&!d8UOn z7d?8^K+=GQ`JVIbhQMm<=9r;SOO{zGten->xzgW-de7P2DWjgPkdrom%p@_=7wM1+qziL-rwLSICWYT;DT-eC(621{#0?e~Kas)-i_Hy^3{ln| zXA4{jYq-$xY<{<7;KLW9>XB;GVWdI%@!0WC@!L~s!i{q~TD_s!k_=q4$I;wh^X-5K mWmHHBlpn(U|F0{fR}4lkOtP)Vz5M<}0Nh+o! zX>1$E702J~K}pm>9oA(_vSiD0Z6}U{I&I=JX;K75g5E`wpg>SGXwagc0`y8EMLv=O zMT#D1(*_0F0!51+Uy7hj+Am2ROGw~cPTV+gd`q@u(Xy?J6vaKJ*hf)XN~E+T_n2MI z4-1>3$mNRvotZc9y?G-+@Tdl;1P}oT{yPgG0nFi#WdJdNIDm$SfG?=P*M|VE0W<DTxf->NPH)Bz;%v7VOA2|Z$XEI?>Mc0>0>F8%uf&YG8(4#*j0iBA?tvJFZPi798thcZaM)&<@$`cTKoy!7 ziIv~rC9JAmi^mN-{6)$VtU}XL2e|6>^<;xF)n)9=HEcQ)USCWx%-|uQ_5vFJEd4nw zsa}p#?P0Gk!$x!*n^Ja39l$i&p$x!=>Ko7i9%gzhEQ*x>pc-w`8{S*{UaP(iE#7k; zUxiCd1>D7T z)oZYo54ruZETUjrZ^mh!yM9I&RS%&9Yl=&r$ry^ziW7ae8ogVp*P;13;?k%23>E#j zO&ADv#Vh5C>MdxwaLHFDiXMv&H1{0#JC zqGZ0{3J&u%mUBb$$1&Bb(0t>P7se=ldyP&_=$%o$9WB!9j-0zxNTOx89^j(==XOB# zjW`?ee{z}kr~sD$wgG%(v-{R=)jP3Q*KIy)>7oI=kLJ<1o!_dhs<#7Nu=9MJd7_vw zIBwgF&OX&!&<5c+#0bF0FgR(4@HVO5j*|09xublDEq>I76hb>x@8li3TZ{nIqYtOJ z23EA{-6fL~!3fv!J3mv@If_$#9dJBQ!Op1-X_fNc9e%OHPP*Pbb;h0HZPW zUuPiZb)s^?jA8SO=WWvGuX;Pc<-Fg-6!Jq`$yYWLAV7iaXi^?O;`J33!6gjK=II~H zQ}xt9;`3Z)a%YDjIW9&gTN+4h8J(E(`l5;;nz!)zsGhRm91HckN2R>*&=5Z)dU`cQ zqhTjsna6x8AJx}zPdlqjqOoPa>NU7phr=vGn4)OS%C@XJU%J3PQ&wF!)>LoilKX*c zsJaC#s9wv{q5Q*jOCeATsy73ivg@Ka&ZFW~YsE?ctEs*Y7aMbY#3jtLD@t|Me}Pm| zUm-rYg>=`^cca>0KA?&GGY~`~e_=D+-U`uK?XMr`7bh*ehb0o|T*>WdUQzWnUpil< z$8IAxtsy{YSA;~$l%Kzu_ZdFhBYbVE;lk+#vb7=k=zn0&SJ`1xn4f+&=kHfkoiJbJ zEgNK8L(1W5{JXuekTcxYlJeRaM9QJ55{CWO+@rXNcsBblbE{tF+j3BZEIyVB^0rnj zx@=K>AKv@ZSo|mlIaHVNJ$?>Ja18%^B(wf?njEV0q=^6!$7OPPsGegy;P{MbTpPP= z;Y2poYYR!r;jqYcT$8jcIG#gwzN#TGWOKJHQ(f|5nP$!kMA4@{mhs#&)vLMcfn(WR zY?<9=qtXo*h!}9%^q#0 zVx5{Y@%y(ESuh^ow^5o>l)s&H|0=|r8l`V+Pq~AnZ|=~~PSB4SuE&zZy~<2vsvc&& zt&7c-M4ETK5kmCth&R`ORHYT z{G5$bJn2q8tvU~-mjX*w=adK-ctzD^)}1qp#8pZuS(v8lUS0R%=Tb^R^U2p2!=Abcx9_@qq-Z*_={^lcu(DN*9$GI}iMhPoa-6wadK}=DfeM9`# ztq=*(SBA8Ic(W=;`(T{>{)F(=Eg*pQ=ac#=w%xJIK-HW4v`SffYgBv0`*rl{3#Z`) zA1(_*vmI6EGxgG8)X1rl45N-u+e!!B$e5Ol70&@EHN&xv2oiU{mmCU6#>ys6Nuh$o zO}Ubz3?#po40ME~-`4Es1pz5|o_Km;igc#)>8=!1$^6U&bf;5K6rD^YeB{Skodih1 zo3?2`dxJc&3mU_8bVhyd?E=oA{PBd;92Ryo!>pn{_con&c(YuW2?x=J*Dp*aAN@Ck zWfE6tVd0sWq8=E4N|_{esswHbOr*Rt!9%AolPtDdjwyla*cX@qiErUiawvS}R+313 z>%9_$t6qwr4Ielwm9sKj^-@GKlIDWtgy5`-WF$@ct!^TxD1@MTX*erT1yaF#eRBLt zHAu$JO+HD6=h(vykc`bjI359nD z>{p(Am0r!e9}{ZJr9=Cmy&6km5NNFu_O@&PJmbhky9`#~Mb!i2n^SdjjZxBEDIeaa z^u9`?c?d5=%A`a4NPFfENm@|WMJ%c=;}8B%prdbGp{H+n}?>I;}!wMlg)n9V&UymDYL)VGWF z)aMeO4o@5N@}NrI0Wh@qQ)b|W?OD69sh-rAYwt!Ok#_`)#*)eYfzZ?YX;);q@J3aO zzN3ZB%ZVtETbe-8^^;fGEgmgr{FJHs6@VH>-SYBxc9Oe0j5~93PU#z`(IaHsVPpjkj7%8XQEr;qc;1$GuPYb|s_TS`A zik@QEW5Zb;d8Lp_Zq-#B71&grtywVzun^62nVKvUwgpyP0C59*2^MyRnNbL(i;kMQ zidA0k7G#N+hLKXkl^MIOFK0#76=sT(-AK@cMk?m7)<};jmkNl%*beDg%+~-);W8UJ zXyq$4bPJ1}CYA>2Jb?n}r>==Rno~a#Q4nu!)?V{CM_p(PlZDL#47aALUu0lJ46i=m ztfsnx3m+x$zVPU=QAn!}6vWSM(q6yp?Ovn8=X=Z+hJHM1Grz$fks+uZm%|#1nGe-!k0Ijds6z=nEpR+zaTW? zto6#)RFCIzM(5C8JR^L%6WY>=>x2k9n&jv1)n7QRzkE^8f9-0%2sLHolWWB<_CRZu zdFZ4GRr~F6r$2pl8UZd?dZFc|>sP%G$xuaCe{xJZwAUP8CzZ1J;8yX$t&r6883(i8 zNSR0~vw1ivXwM(l$Glz+-U47bpCg}w1v9v3ceJpJvIqVAg!=3;>9J2hjZBu9sv%LR zcXu9dm~K>i{$1^ri>`j|a#9)nmV4rSRFC1Rs4|cDaMq8HsZSh{es~A0t+McP2chw# z_RF_DpFjc@4$RA$mS=I2F@SYTld#-JAD&8n?`8Sn?c`G(AP`H-8*Ic_AVcVTQT6%b zbcBJdqn5m9SoxQvi2}6e4Cc7UjH3MNE#Xh^lRMT5w=@|ki%nr?-Qg4VPfZ5~=x364u5<7VY z@-G5N6L#$OWoOx1B%I=vZ2~yU7f0Gho6V9tP#Rvvd1NQH`ydu_a+@9;I*YT|m*dQa z02gpN7l=C4Ua}S^#tz`vHW1ga*h#g=Sr~q$8oxcq0-H{5{xN};Cj;?#pV5G_I2p@! zc*~b54A8Y=i|6ioE38E)jKfKI&TsUiqGi{Ds(YO}%rxZ~#*DfVzjkYTrz@CSy&iM) z8B0xIf+jS6-6~q<;-+3PJT_tv7PxA)g;R+G`G~8T3a)SEb{7B6x-5l}N|6Yb0CIit zs23afr5gQatZG-Xe)}-SOozQmM)Gdk##3m&w4wHFq?@`#7^mYZI^8_N)6F^)Xurq+ z8!!>i>or4;Bd7KDx|SaUTeK>e^;d!4h!&iEf-{h?KflgOK#~E~Q=~lh5uDvkv-0iO zKn)(0Ee9nts;8L5BZTfe&F4P+Mx26AxHk*84r8jPNT##!n(=YmS3%nleyKIsU9q-G zBn^|QFOaf&BG~j5bm+4BJsPaX$@eVYvTR!?Y^$DPGCj46q_?V=Wu}6C1vYB|53d)o zTgMNoFJPY5H(~2fHGP+-d=k1BQ!}wy@26n};49V5EEsyBbPhrYhyM|0Qudm#Su6Mv zbG~;g`BwD>l6Wk1`1SZVrM9!s(mPts;gdSPENc#$fXz3xKXC*_m2r?J_$vjY2ELw9 aWb%Jqbod*sW)h+R0000DPVk{qeou^Sr;_=lkC0``$Q9bEuGjv;Y7A5Hh)AX#KMn z|GW6Pe&SxhL<<0L0$^gOXA_jmoO1CN8xe0`_hre;E%biAk z(Z-IPBcg6h_}RRmL$#j(ZnxWtBc+fnVPsFcFC|Go#zb{R&tG;$eSv~#3crZbFRA>; zCN2J|jSC(!y>$5XMxrJKeC(Qg>rkv~WptYs3o*5uI@jM%ZO||AY1_X=m5_hqWEsKZ zxP`Vb!$wO;)BJ@Z`0I=uT)okUmzp$HQ#r*$xTP$=C+Jk8X!kCB_k*(H&-sJq;!UJ(s;~xURS-~X{QFDb^wi(*lsQ3$8O~B1=h6hTnpslMO9Y$TC>MuNy!+X` zKXMGCMVDfgcviZdzftc!qy{rfnu5iF?k()Q!))ho?m$)?WFOwJ%TgICm|3ISzX-`T z?&J9#d^>+F{@^{C?HIASr4h83J5zH9KZ}E@TKuP(-oi&bZ@FX!^pUG5;n|7-3@biJ zlaQc5kL*@>MyF&z*y%?-_6t_j$kpM1LElqini#+m=iOqxN6E)Rn8Dy#*+P|0SzQ%a z_4=@YBnT50nrmDX+7PdCm!HP@+A+F#pehyVGw~Fgg}wsP$9VSr*S5X~?{$XhxCnBy zB1zMIpR&d935j)YKIsIx7a0uT^$U}JI6786jxG(6Q-1C8AQ1F6jsEeOI$kk3OKW6q z)CuuL3&zY89`aS3+T>LD;;L|e^zGyG0gvL#?bSygv*B#!O47U1je#UP_C(%%*C4q| zjrP5Yw33gvh%p5lPvOgetZ8C_;(bi*_{7Y+HZ5V@KdiFR0k!uXFRW)SCxTw<7gp*v z79H7FWn`FzokciykGFJ82gk~=o}MW!`ZzkKf=^F&-F|b3r_*$lcxN-$A`d^r~P=ovYcG zGCg5rg{?t+^s^@ZQn zWC+aouFvT}*2zH)I5W9zAF<$Pa+3?^?6<|Bpq7J|O_*!tioLit zrEd8nGG-6uGIvp!xO;1VTB)XDY)60Wc-y5OEhSyG_Q>2A%>*W`1)GFK)OaZpXEJfA zfiHm@5?gW43w+8eqvyRtTh(KP^&fWoZ3Q@Do$!@An+RfoZ+ zyO*mQNM0srPXrBhH96g;QeHLKAN80JQFEwYukLO_Cth4Kh?u(PUl&o5>?LRFz?b=D zJGuuh9&EC3tjYSk9OJ+$b|p^LwXs&L=6C75J@lR~$XHBT#QNvHaW;9cBE&ICDCgjVPslwSj@;`JPvCLU|G2BVPkTp=czPFyMK_ z6XGGFq!`QYmp^|q9YMs}v56&GRgAKzS&t1K*SDpyv&}I*>X}HtPCagslOHD4k8(PS z%>(tUFF{*~+C_KAUN&Y(`J1`Hqwu&6t!mP8O&4LrDFwztCsCM4P|XLylxO=nA!|up zi}X5&tOJb&99@T8ixoz<43-aE)M z7T~d%ieiK5x;n@(bj&(OAexLt@Mr_XVfL4Zc!ZDca`eJ?wXyBt{t m!zujzjr!POmKC&izU2w-AlZdj?0eEt`Sg12M< diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index c71cd4db..4470be2e 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -165,6 +165,16 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; + const donorNames = [ + 'J', + 'Julian', + 'matt_3050', + 'Daniel', + '283Fabio', + 'laflame', + 'Elias el Autentico', + 'Faylyne', + ]; // Match SettingsGroup color logic final cardColor = isDark @@ -207,16 +217,15 @@ class _RecentDonorsCard extends StatelessWidget { ), ), const SizedBox(height: 16), - _DonorTile(name: 'J', colorScheme: colorScheme), - _DonorTile(name: 'Julian', colorScheme: colorScheme), - _DonorTile(name: 'matt_3050', colorScheme: colorScheme), - _DonorTile(name: 'Daniel', colorScheme: colorScheme), - _DonorTile(name: '283Fabio', colorScheme: colorScheme), - _DonorTile(name: 'laflame', colorScheme: colorScheme), - _DonorTile( - name: 'Elias el Autentico', - colorScheme: colorScheme, - showDivider: false, + Wrap( + spacing: 8, + runSpacing: 8, + children: donorNames + .map( + (name) => + _SupporterChip(name: name, colorScheme: colorScheme), + ) + .toList(), ), ], ), @@ -348,55 +357,45 @@ class _DonateCardItem extends StatelessWidget { } } -class _DonorTile extends StatelessWidget { +class _SupporterChip extends StatelessWidget { final String name; final ColorScheme colorScheme; - final bool showDivider; - const _DonorTile({ - required this.name, - required this.colorScheme, - this.showDivider = true, - }); + const _SupporterChip({required this.name, required this.colorScheme}); @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: colorScheme.primaryContainer, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: colorScheme.onPrimaryContainer, - ), + return Material( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 10, + backgroundColor: colorScheme.primary.withValues(alpha: 0.2), + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: colorScheme.primary, ), ), - const SizedBox(width: 12), - Text( - name, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface), + ), + const SizedBox(width: 8), + Text( + name, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w500, ), - ], - ), + ), + ], ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index a8aa5fe2..a74f25d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,10 +80,13 @@ flutter_launcher_icons: android: true ios: true image_path: "icon.png" - adaptive_icon_background: "#1a1a2e" - adaptive_icon_foreground: "icon.png" + image_path_android: "icon_android.png" + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "icon_foreground_android.png" + adaptive_icon_foreground_inset: 16 ios_content_mode: scaleAspectFill remove_alpha_ios: true + background_color_ios: "#000000" flutter: uses-material-design: true From c3f8b48bf72eb00816c42d0d70be923ef50b89f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:28:10 +0000 Subject: [PATCH 05/38] fix(deps): update go dependencies --- go_backend/go.mod | 4 ++-- go_backend/go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go_backend/go.mod b/go_backend/go.mod index d903afd6..a66c22f7 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -5,12 +5,12 @@ go 1.25.0 toolchain go1.26.0 require ( - github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 + github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/go-flac/v2 v2.0.4 github.com/refraction-networking/utls v1.8.2 - golang.org/x/mobile v0.0.0-20260209203831-923679eb55af + golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 golang.org/x/net v0.50.0 ) diff --git a/go_backend/go.sum b/go_backend/go.sum index e1f185bb..50e29433 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -8,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= +github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= @@ -36,6 +38,8 @@ golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBr golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= +golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= +golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= From 813ed790738bf2e504f03e9d17c994beae80ee11 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 17 Feb 2026 20:56:34 +0700 Subject: [PATCH 06/38] refactor: conditionally show lyrics settings only when embed lyrics is enabled --- .../settings/download_settings_page.dart | 140 +++++++++--------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 4efc7e55..ac07f913 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -306,80 +306,80 @@ class _DownloadSettingsPageState extends ConsumerState { onChanged: (value) => ref .read(settingsProvider.notifier) .setEmbedLyrics(value), + showDivider: settings.embedLyrics, ), - SettingsItem( - icon: Icons.lyrics_outlined, - title: context.l10n.lyricsMode, - subtitle: settings.embedLyrics - ? _getLyricsModeLabel(context, settings.lyricsMode) - : context.l10n.extensionsDisabled, - onTap: settings.embedLyrics - ? () => _showLyricsModePicker( - context, - ref, - settings.lyricsMode, - ) - : null, - ), - SettingsItem( - icon: Icons.source_outlined, - title: 'Lyrics Providers', - subtitle: _getLyricsProvidersSubtitle( - settings.lyricsProviders, - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LyricsProviderPriorityPage(), + if (settings.embedLyrics) ...[ + SettingsItem( + icon: Icons.lyrics_outlined, + title: context.l10n.lyricsMode, + subtitle: + _getLyricsModeLabel(context, settings.lyricsMode), + onTap: () => _showLyricsModePicker( + context, + ref, + settings.lyricsMode, ), ), - ), - SettingsSwitchItem( - icon: Icons.translate_outlined, - title: 'Netease: Include Translation', - subtitle: settings.lyricsIncludeTranslationNetease - ? 'Append translated lyrics when available' - : 'Use original lyrics only', - value: settings.lyricsIncludeTranslationNetease, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsIncludeTranslationNetease(value), - ), - SettingsSwitchItem( - icon: Icons.text_fields_outlined, - title: 'Netease: Include Romanization', - subtitle: settings.lyricsIncludeRomanizationNetease - ? 'Append romanized lyrics when available' - : 'Disabled', - value: settings.lyricsIncludeRomanizationNetease, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsIncludeRomanizationNetease(value), - ), - SettingsSwitchItem( - icon: Icons.record_voice_over_outlined, - title: 'Apple/QQ Multi-Person Word-by-Word', - subtitle: settings.lyricsMultiPersonWordByWord - ? 'Enable v1/v2 speaker and [bg:] tags' - : 'Simplified word-by-word formatting', - value: settings.lyricsMultiPersonWordByWord, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsMultiPersonWordByWord(value), - ), - SettingsItem( - icon: Icons.language_outlined, - title: 'Musixmatch Language', - subtitle: settings.musixmatchLanguage.isEmpty - ? 'Auto (original)' - : settings.musixmatchLanguage.toUpperCase(), - onTap: () => _showMusixmatchLanguagePicker( - context, - ref, - settings.musixmatchLanguage, + SettingsItem( + icon: Icons.source_outlined, + title: 'Lyrics Providers', + subtitle: _getLyricsProvidersSubtitle( + settings.lyricsProviders, + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LyricsProviderPriorityPage(), + ), + ), ), - showDivider: false, - ), + SettingsSwitchItem( + icon: Icons.translate_outlined, + title: 'Netease: Include Translation', + subtitle: settings.lyricsIncludeTranslationNetease + ? 'Append translated lyrics when available' + : 'Use original lyrics only', + value: settings.lyricsIncludeTranslationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeTranslationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.text_fields_outlined, + title: 'Netease: Include Romanization', + subtitle: settings.lyricsIncludeRomanizationNetease + ? 'Append romanized lyrics when available' + : 'Disabled', + value: settings.lyricsIncludeRomanizationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeRomanizationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.record_voice_over_outlined, + title: 'Apple/QQ Multi-Person Word-by-Word', + subtitle: settings.lyricsMultiPersonWordByWord + ? 'Enable v1/v2 speaker and [bg:] tags' + : 'Simplified word-by-word formatting', + value: settings.lyricsMultiPersonWordByWord, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsMultiPersonWordByWord(value), + ), + SettingsItem( + icon: Icons.language_outlined, + title: 'Musixmatch Language', + subtitle: settings.musixmatchLanguage.isEmpty + ? 'Auto (original)' + : settings.musixmatchLanguage.toUpperCase(), + onTap: () => _showMusixmatchLanguagePicker( + context, + ref, + settings.musixmatchLanguage, + ), + showDivider: false, + ), + ], ], ), ), From cdc583678558223ecbb552176b53727d303ae218 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 18 Feb 2026 00:04:32 +0700 Subject: [PATCH 07/38] fix: rollback Go toolchain to 1.25.7 to fix ARM32 SIGSYS crash Go 1.26.0 runtime uses futex_time64 (syscall 422) which is blocked by seccomp on Android 10 and older ARM32 devices, causing immediate SIGSYS (signal 31) crash on app launch. Downgraded: - toolchain: go1.26.0 -> go1.25.7 - golang.org/x/sys: v0.41.0 -> v0.40.0 - golang.org/x/crypto: v0.48.0 -> v0.47.0 - golang.org/x/mod: v0.33.0 -> v0.32.0 - golang.org/x/text: v0.34.0 -> v0.33.0 - golang.org/x/tools: v0.42.0 -> v0.41.0 --- go_backend/go.mod | 2 +- go_backend/go.sum | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/go_backend/go.mod b/go_backend/go.mod index a66c22f7..fe67fe7e 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend go 1.25.0 -toolchain go1.26.0 +toolchain go1.25.7 require ( github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 diff --git a/go_backend/go.sum b/go_backend/go.sum index 50e29433..3b71ae9b 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= @@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= From 5605930aef8cd6d4182c013a315993b2a966ed49 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 18 Feb 2026 18:05:48 +0700 Subject: [PATCH 08/38] feat: add multi-select share and batch convert in downloaded/local album screens - Add shareMultipleContentUris native handler in MainActivity for ACTION_SEND_MULTIPLE - Add shareMultipleContentUris binding in PlatformBridge - Add _shareSelected and _performBatchConversion methods to DownloadedAlbumScreen and LocalAlbumScreen - Add batch convert bottom sheet UI with format/bitrate selection (MP3/Opus, 128k-320k) - Add share & convert action buttons to selection bottom bar in both screens - Add batch convert with full SAF support: temp copy, write-back, history update - Add share/convert selection strings to l10n (all supported locales + app_en.arb) - Add queue tab selection share/convert feature (queue_tab.dart) - Update donate page - Update go.sum with bumped dependency hashes --- .gitattributes | 20 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 22 + go_backend/go.sum | 18 + lib/l10n/app_localizations.dart | 52 ++ lib/l10n/app_localizations_de.dart | 56 ++ lib/l10n/app_localizations_en.dart | 56 ++ lib/l10n/app_localizations_es.dart | 56 ++ lib/l10n/app_localizations_fr.dart | 56 ++ lib/l10n/app_localizations_hi.dart | 56 ++ lib/l10n/app_localizations_id.dart | 56 ++ lib/l10n/app_localizations_ja.dart | 56 ++ lib/l10n/app_localizations_ko.dart | 56 ++ lib/l10n/app_localizations_nl.dart | 56 ++ lib/l10n/app_localizations_pt.dart | 56 ++ lib/l10n/app_localizations_ru.dart | 56 ++ lib/l10n/app_localizations_tr.dart | 56 ++ lib/l10n/app_localizations_zh.dart | 56 ++ lib/l10n/arb/app_en.arb | 49 +- lib/screens/downloaded_album_screen.dart | 457 ++++++++++++- lib/screens/local_album_screen.dart | 520 ++++++++++++++- lib/screens/queue_tab.dart | 602 +++++++++++++++++- lib/screens/settings/donate_page.dart | 59 +- lib/services/platform_bridge.dart | 8 + 23 files changed, 2512 insertions(+), 23 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d080aebf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +* text=auto eol=lf + +# Windows scripts +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.zip binary +*.jar binary +*.aar binary +*.keystore binary +*.jks binary diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 16e7ffbb..be542a83 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1546,6 +1546,28 @@ class MainActivity: FlutterFragmentActivity() { result.error("share_failed", e.message, null) } } + "shareMultipleContentUris" -> { + val uriStrings = call.argument>("uris") ?: emptyList() + val title = call.argument("title") ?: "" + try { + val uris = ArrayList(uriStrings.size) + for (s in uriStrings) { + uris.add(Uri.parse(s)) + } + val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + setType("audio/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (title.isNotBlank()) { + putExtra(Intent.EXTRA_SUBJECT, title) + } + } + startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" })) + result.success(true) + } catch (e: Exception) { + result.error("share_failed", e.message, null) + } + } "fetchLyrics" -> { val spotifyId = call.argument("spotify_id") ?: "" val trackName = call.argument("track_name") ?: "" diff --git a/go_backend/go.sum b/go_backend/go.sum index 3b71ae9b..50e29433 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= @@ -28,20 +30,36 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= +golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= +golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= +golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 40a8f4f7..195b2f38 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5257,6 +5257,58 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Conversion failed'** String get trackConvertFailed; + + /// Share button text with count in selection mode + /// + /// In en, this message translates to: + /// **'Share {count} {count, plural, =1{track} other{tracks}}'** + String selectionShareCount(int count); + + /// Snackbar when no selected files exist on disk + /// + /// In en, this message translates to: + /// **'No shareable files found'** + String get selectionShareNoFiles; + + /// Convert button text with count in selection mode + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}}'** + String selectionConvertCount(int count); + + /// Snackbar when no selected tracks support conversion + /// + /// In en, this message translates to: + /// **'No convertible tracks selected'** + String get selectionConvertNoConvertible; + + /// Confirmation dialog title for batch conversion + /// + /// In en, this message translates to: + /// **'Batch Convert'** + String get selectionBatchConvertConfirmTitle; + + /// Confirmation dialog message for batch conversion + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.'** + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ); + + /// Snackbar during batch conversion progress + /// + /// In en, this message translates to: + /// **'Converting {current} of {total}...'** + String selectionBatchConvertProgress(int current, int total); + + /// Snackbar after batch conversion completes + /// + /// In en, this message translates to: + /// **'Converted {success} of {total} tracks to {format}'** + String selectionBatchConvertSuccess(int success, int total, String format); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d7a5349b..51d9f9b2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2987,4 +2987,60 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8225f5c6..444ac354 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2966,4 +2966,60 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f854b116..bf82005a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2966,6 +2966,62 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 566fdcc1..b5f1a283 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2972,4 +2972,60 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 44e45c90..d18df768 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2966,4 +2966,60 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f8b81ecc..a5205b83 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2979,4 +2979,60 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8284e093..82fd113e 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2952,4 +2952,60 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a80520d2..f26d0895 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2965,4 +2965,60 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 151a8805..397122f8 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2966,4 +2966,60 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 89eaf853..442e8f1b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2966,6 +2966,62 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d38edcc9..e3d6a1d8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3064,4 +3064,60 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 1706ce88..0c14cf80 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2981,4 +2981,60 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 04d3eba1..2d4a94c4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2966,6 +2966,62 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 148fbd13..7a6dd20c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2258,5 +2258,52 @@ } }, "trackConvertFailed": "Conversion failed", - "@trackConvertFailed": {"description": "Snackbar when conversion fails"} + "@trackConvertFailed": {"description": "Snackbar when conversion fails"}, + + "selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", + "@selectionShareCount": { + "description": "Share button text with count in selection mode", + "placeholders": { + "count": {"type": "int"} + } + }, + "selectionShareNoFiles": "No shareable files found", + "@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"}, + "selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}", + "@selectionConvertCount": { + "description": "Convert button text with count in selection mode", + "placeholders": { + "count": {"type": "int"} + } + }, + "selectionConvertNoConvertible": "No convertible tracks selected", + "@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"}, + "selectionBatchConvertConfirmTitle": "Batch Convert", + "@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"}, + "selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.", + "@selectionBatchConvertConfirmMessage": { + "description": "Confirmation dialog message for batch conversion", + "placeholders": { + "count": {"type": "int"}, + "format": {"type": "String"}, + "bitrate": {"type": "String"} + } + }, + "selectionBatchConvertProgress": "Converting {current} of {total}...", + "@selectionBatchConvertProgress": { + "description": "Snackbar during batch conversion progress", + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}", + "@selectionBatchConvertSuccess": { + "description": "Snackbar after batch conversion completes", + "placeholders": { + "success": {"type": "int"}, + "total": {"type": "int"}, + "format": {"type": "String"} + } + } } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 4f1270f3..8faa85a7 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -4,7 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -939,6 +944,364 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } + /// Share selected tracks via system share sheet + Future _shareSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final safUris = []; + final filesToShare = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + final path = item.filePath; + if (isContentUri(path)) { + if (await fileExists(path)) safUris.add(path); + } else if (await fileExists(path)) { + filesToShare.add(XFile(path)); + } + } + + if (safUris.isEmpty && filesToShare.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionShareNoFiles)), + ); + } + return; + } + + // Share SAF content URIs via native intent + if (safUris.isNotEmpty) { + try { + if (safUris.length == 1) { + await PlatformBridge.shareContentUri(safUris.first); + } else { + await PlatformBridge.shareMultipleContentUris(safUris); + } + } catch (_) {} + } + + // Share regular files via SharePlus + if (filesToShare.isNotEmpty) { + await SharePlus.instance.share(ShareParams(files: filesToShare)); + } + } + + /// Show batch convert bottom sheet + void _showBatchConvertSheet( + BuildContext context, + List allTracks, + ) { + String selectedFormat = 'MP3'; + String selectedBitrate = '320k'; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final formats = ['MP3', 'Opus']; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.selectionBatchConvertConfirmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + _performBatchConversion( + allTracks: allTracks, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + context.l10n.selectionConvertCount(_selectedIds.length), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _performBatchConversion({ + required List allTracks, + required String targetFormat, + required String bitrate, + }) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + // For SAF items, use safFileName to detect format (filePath is content:// URI) + final nameToCheck = (item.safFileName != null && item.safFileName!.isNotEmpty) + ? item.safFileName!.toLowerCase() + : item.filePath.toLowerCase(); + final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.mp3') ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' + : null; + if (ext != null && ext != targetFormat) selected.add(item); + } + + if (selected.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), + ); + } + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + context.l10n.selectionBatchConvertConfirmMessage( + selected.length, targetFormat, bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selected.length; + final historyDb = HistoryDatabase.instance; + + for (int i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.selectionBatchConvertProgress(i + 1, total)), + duration: const Duration(seconds: 30), + ), + ); + + try { + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final v = value.toString().trim(); + if (v.isEmpty) return; + metadata[key.toUpperCase()] = v; + }); + } + } catch (_) {} + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, coverOutput, + ); + if (coverResult['error'] == null) coverPath = coverOutput; + } catch (_) {} + + String workingPath = item.filePath; + final isSaf = isContentUri(item.filePath); + String? safTempPath; + + if (isSaf) { + safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, + ); + + if (coverPath != null) { + try { await File(coverPath).delete(); } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + continue; + } + + if (isSaf) { + final treeUri = item.downloadTreeUri; + final relativeDir = item.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final oldFileName = item.safFileName ?? ''; + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { await File(newPath).delete(); } catch (_) {} + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + continue; + } + + try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + await historyDb.updateFilePath(item.id, safUri, newSafFileName: newFileName); + } + try { await File(newPath).delete(); } catch (_) {} + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + } else { + await historyDb.updateFilePath(item.id, newPath); + } + + successCount++; + } catch (_) {} + } + + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat), + ), + ), + ); + } + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -991,9 +1354,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.downloadedAlbumSelectedCount( - selectedCount, - ), + context.l10n.downloadedAlbumSelectedCount(selectedCount), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), @@ -1030,7 +1391,36 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Action buttons row: Share, Convert + Row( + children: [ + Expanded( + child: _DownloadedAlbumSelectionActionButton( + icon: Icons.share_outlined, + label: context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _shareSelected(tracks) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DownloadedAlbumSelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, tracks) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( @@ -1064,3 +1454,62 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } } + +class _DownloadedAlbumSelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _DownloadedAlbumSelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index a86bc2aa..5d654943 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -3,9 +3,13 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; /// Screen to display tracks from a local library album @@ -820,6 +824,428 @@ class _LocalAlbumScreenState extends ConsumerState { ); } + /// Share selected local tracks + Future _shareSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final safUris = []; + final filesToShare = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + final path = item.filePath; + if (isContentUri(path)) { + if (await fileExists(path)) safUris.add(path); + } else if (await fileExists(path)) { + filesToShare.add(XFile(path)); + } + } + + if (safUris.isEmpty && filesToShare.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionShareNoFiles)), + ); + } + return; + } + + // Share SAF content URIs via native intent + if (safUris.isNotEmpty) { + try { + if (safUris.length == 1) { + await PlatformBridge.shareContentUri(safUris.first); + } else { + await PlatformBridge.shareMultipleContentUris(safUris); + } + } catch (_) {} + } + + // Share regular files via SharePlus + if (filesToShare.isNotEmpty) { + await SharePlus.instance.share(ShareParams(files: filesToShare)); + } + } + + /// Show batch convert bottom sheet + void _showBatchConvertSheet( + BuildContext context, + List allTracks, + ) { + String selectedFormat = 'MP3'; + String selectedBitrate = '320k'; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final formats = ['MP3', 'Opus']; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.selectionBatchConvertConfirmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + _performBatchConversion( + allTracks: allTracks, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + context.l10n.selectionConvertCount(_selectedIds.length), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _performBatchConversion({ + required List allTracks, + required String targetFormat, + required String bitrate, + }) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + // Detect current format: prefer item.format field (works for SAF too), + // fall back to file extension for regular paths + String? currentFormat; + if (item.format != null && item.format!.isNotEmpty) { + final fmt = item.format!.toLowerCase(); + if (fmt == 'flac') { + currentFormat = 'FLAC'; + } else if (fmt == 'mp3') { + currentFormat = 'MP3'; + } else if (fmt == 'opus' || fmt == 'ogg') { + currentFormat = 'Opus'; + } + } + if (currentFormat == null) { + // Fallback: try file extension (works for regular paths) + final lower = item.filePath.toLowerCase(); + if (lower.endsWith('.flac')) { + currentFormat = 'FLAC'; + } else if (lower.endsWith('.mp3')) { + currentFormat = 'MP3'; + } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { + currentFormat = 'Opus'; + } + } + if (currentFormat != null && currentFormat != targetFormat) { + selected.add(item); + } + } + + if (selected.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), + ); + } + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + context.l10n.selectionBatchConvertConfirmMessage( + selected.length, targetFormat, bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selected.length; + final localDb = LibraryDatabase.instance; + + for (int i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.selectionBatchConvertProgress(i + 1, total)), + duration: const Duration(seconds: 30), + ), + ); + + try { + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final v = value.toString().trim(); + if (v.isEmpty) return; + metadata[key.toUpperCase()] = v; + }); + } + } catch (_) {} + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, coverOutput, + ); + if (coverResult['error'] == null) coverPath = coverOutput; + } catch (_) {} + + final isSaf = isContentUri(item.filePath); + String workingPath = item.filePath; + String? safTempPath; + + if (isSaf) { + // Copy SAF file to temp for conversion + safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, // Only delete original for regular files + ); + + if (coverPath != null) { + try { await File(coverPath).delete(); } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + continue; + } + + if (isSaf) { + // For SAF: derive the parent tree URI and relative dir from the content URI, + // then create new SAF file and delete old one + // + // Parse the SAF URI to get the tree document path: + // content://...tree/...document/.../oldName.flac + // We need tree URI and relative dir to create the new file + final uri = Uri.parse(item.filePath); + final pathSegments = uri.pathSegments; + + // Try to find 'tree' and 'document' segments + String? treeUri; + String relativeDir = ''; + String oldFileName = ''; + + // Typical SAF document URI pattern: + // content://authority/tree//document/ + final treeIdx = pathSegments.indexOf('tree'); + final docIdx = pathSegments.indexOf('document'); + if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { + final treeId = pathSegments[treeIdx + 1]; + treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + } + + if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { + final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); + final slashIdx = docPath.lastIndexOf('/'); + if (slashIdx >= 0) { + oldFileName = docPath.substring(slashIdx + 1); + // Relative dir is everything after the tree id's directory base + final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length + ? Uri.decodeFull(pathSegments[treeIdx + 1]) + : ''; + if (treeId.isNotEmpty && docPath.startsWith(treeId)) { + final afterTree = docPath.substring(treeId.length); + final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; + final lastSlash = trimmed.lastIndexOf('/'); + relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; + } + } else { + oldFileName = docPath; + } + } + + if (treeUri != null && oldFileName.isNotEmpty) { + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { await File(newPath).delete(); } catch (_) {} + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + continue; + } + + // Delete old SAF file + try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + await localDb.deleteByPath(item.filePath); + } + + // Clean up temp files + try { await File(newPath).delete(); } catch (_) {} + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + } else { + // Regular file: just remove old entry, rescan will find the new one + await localDb.deleteByPath(item.filePath); + } + + successCount++; + } catch (_) {} + } + + // Reload local library to pick up converted files + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat), + ), + ), + ); + } + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -872,9 +1298,7 @@ class _LocalAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.downloadedAlbumSelectedCount( - selectedCount, - ), + context.l10n.downloadedAlbumSelectedCount(selectedCount), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), @@ -911,7 +1335,36 @@ class _LocalAlbumScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Action buttons row: Share, Convert + Row( + children: [ + Expanded( + child: _LocalAlbumSelectionActionButton( + icon: Icons.share_outlined, + label: context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _shareSelected(tracks) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _LocalAlbumSelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, tracks) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( @@ -945,3 +1398,62 @@ class _LocalAlbumScreenState extends ConsumerState { ); } } + +class _LocalAlbumSelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _LocalAlbumSelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4327516c..7f1b40f4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5,7 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -14,6 +18,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; @@ -2922,6 +2927,512 @@ class _QueueTabState extends ConsumerState { ); } + /// Share selected tracks via system share sheet + Future _shareSelected(List allItems) async { + final itemsById = {for (final item in allItems) item.id: item}; + final safUris = []; + final filesToShare = []; + + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + final path = item.filePath; + if (isContentUri(path)) { + if (await fileExists(path)) safUris.add(path); + } else if (await fileExists(path)) { + filesToShare.add(XFile(path)); + } + } + + if (safUris.isEmpty && filesToShare.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionShareNoFiles)), + ); + } + return; + } + + // Share SAF content URIs via native intent + if (safUris.isNotEmpty) { + try { + if (safUris.length == 1) { + await PlatformBridge.shareContentUri(safUris.first); + } else { + await PlatformBridge.shareMultipleContentUris(safUris); + } + } catch (_) {} + } + + // Share regular files via SharePlus + if (filesToShare.isNotEmpty) { + await SharePlus.instance.share( + ShareParams(files: filesToShare), + ); + } + } + + /// Show batch convert bottom sheet for selected tracks + void _showBatchConvertSheet( + BuildContext context, + List allItems, + ) { + String selectedFormat = 'MP3'; + String selectedBitrate = '320k'; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final formats = ['MP3', 'Opus']; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.selectionBatchConvertConfirmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + _performBatchConversion( + allItems: allItems, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + context.l10n.selectionConvertCount( + _selectedIds.length, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + /// Perform batch conversion on selected tracks + Future _performBatchConversion({ + required List allItems, + required String targetFormat, + required String bitrate, + }) async { + final itemsById = {for (final item in allItems) item.id: item}; + final selectedItems = []; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + // Detect format: use safFileName for download history SAF items, + // item.localItem?.format for local library items, file extension as fallback + String nameToCheck; + if (item.historyItem?.safFileName != null && + item.historyItem!.safFileName!.isNotEmpty) { + nameToCheck = item.historyItem!.safFileName!.toLowerCase(); + } else if (item.localItem?.format != null && + item.localItem!.format!.isNotEmpty) { + // Synthesize a fake extension to keep detection unified + nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; + } else { + nameToCheck = item.filePath.toLowerCase(); + } + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null && ext != targetFormat) { + selectedItems.add(item); + } + } + + if (selectedItems.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.selectionConvertNoConvertible), + ), + ); + } + return; + } + + // Confirm + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + context.l10n.selectionBatchConvertConfirmMessage( + selectedItems.length, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selectedItems.length; + final historyDb = HistoryDatabase.instance; + + for (int i = 0; i < total; i++) { + if (!mounted) break; + final item = selectedItems[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + // Read metadata from file + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = + await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final v = value.toString().trim(); + if (v.isEmpty) return; + metadata[key.toUpperCase()] = v; + }); + } + } catch (_) {} + + // Extract cover art + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + // Handle SAF vs regular file + String workingPath = item.filePath; + final isSaf = isContentUri(item.filePath); + String? safTempPath; + + if (isSaf) { + safTempPath = + await PlatformBridge.copyContentUriToTemp(item.filePath); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + // Convert + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, + ); + + // Cleanup cover temp + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + // Handle SAF write-back + if (isSaf && item.historyItem != null) { + final hi = item.historyItem!; + final treeUri = hi.downloadTreeUri; + final relativeDir = hi.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final oldFileName = hi.safFileName ?? ''; + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = + dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; + final newExt = + targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + // Delete old SAF file + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + + // Update history + await historyDb.updateFilePath( + hi.id, + safUri, + newSafFileName: newFileName, + ); + } + // Cleanup temp files + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else if (isSaf && item.localItem != null) { + // Local library SAF item: parse content URI to derive tree and dir + final uri = Uri.parse(item.filePath); + final pathSegments = uri.pathSegments; + + String? treeUri; + String relativeDir = ''; + String oldFileName = ''; + + final treeIdx = pathSegments.indexOf('tree'); + final docIdx = pathSegments.indexOf('document'); + if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { + final treeId = pathSegments[treeIdx + 1]; + treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + } + if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { + final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); + final slashIdx = docPath.lastIndexOf('/'); + if (slashIdx >= 0) { + oldFileName = docPath.substring(slashIdx + 1); + final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length + ? Uri.decodeFull(pathSegments[treeIdx + 1]) + : ''; + if (treeId.isNotEmpty && docPath.startsWith(treeId)) { + final afterTree = docPath.substring(treeId.length); + final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; + final lastSlash = trimmed.lastIndexOf('/'); + relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; + } + } else { + oldFileName = docPath; + } + } + + if (treeUri != null && oldFileName.isNotEmpty) { + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { await File(newPath).delete(); } catch (_) {} + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + continue; + } + + try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + await LibraryDatabase.instance.deleteByPath(item.filePath); + } + + // Cleanup temp files + try { await File(newPath).delete(); } catch (_) {} + if (safTempPath != null) { + try { await File(safTempPath).delete(); } catch (_) {} + } + } else if (item.historyItem != null) { + // Regular file - update history path + await historyDb.updateFilePath( + item.historyItem!.id, + newPath, + ); + } else if (item.localItem != null) { + // Regular local library file - delete old db entry, rescan picks up new file + await LibraryDatabase.instance.deleteByPath(item.filePath); + } + + successCount++; + } catch (_) { + // Continue to next item on error + } + } + + // Reload history and local library to reflect path changes in UI + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), + ), + ), + ); + } + } + /// Bottom action bar for selection mode (Material Design 3 style) Widget _buildSelectionBottomBar( BuildContext context, @@ -3013,7 +3524,36 @@ class _QueueTabState extends ConsumerState { ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Action buttons row: Share, Convert, Delete + Row( + children: [ + Expanded( + child: _SelectionActionButton( + icon: Icons.share_outlined, + label: context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _shareSelected(unifiedItems) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, unifiedItems) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, @@ -3971,3 +4511,63 @@ class _FilterChip extends StatelessWidget { ); } } + +/// Reusable action button for selection mode bottom bar +class _SelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _SelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 4470be2e..412f8701 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -174,6 +174,7 @@ class _RecentDonorsCard extends StatelessWidget { 'laflame', 'Elias el Autentico', 'Faylyne', + 'Jul', ]; // Match SettingsGroup color logic @@ -357,6 +358,13 @@ class _DonateCardItem extends StatelessWidget { } } +int _cr(String v) { + int r = 0x1F; + for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } + return r; +} +const _cv = {998370}; + class _SupporterChip extends StatelessWidget { final String name; final ColorScheme colorScheme; @@ -365,32 +373,57 @@ class _SupporterChip extends StatelessWidget { @override Widget build(BuildContext context) { + final e = _cv.contains(_cr(name)); + final chipColor = e + ? const Color(0xFFFFF3E0) + : colorScheme.secondaryContainer; + final accentColor = e + ? const Color(0xFFFF8F00) + : colorScheme.primary; + final isDark = Theme.of(context).brightness == Brightness.dark; + final effectiveChipColor = e && isDark + ? const Color(0xFF3E2723) + : chipColor; + return Material( - color: colorScheme.secondaryContainer, + color: effectiveChipColor, borderRadius: BorderRadius.circular(20), - child: Padding( + child: Container( + decoration: e + ? BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: accentColor.withValues(alpha: 0.4), + width: 1, + ), + ) + : null, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( radius: 10, - backgroundColor: colorScheme.primary.withValues(alpha: 0.2), - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), + backgroundColor: accentColor.withValues(alpha: 0.2), + child: e + ? Icon(Icons.star_rounded, size: 12, color: accentColor) + : Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: accentColor, + ), + ), ), const SizedBox(width: 8), Text( name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w500, + color: e + ? accentColor + : colorScheme.onSecondaryContainer, + fontWeight: e ? FontWeight.w600 : FontWeight.w500, ), ), ], diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f2351aeb..9b25456f 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -244,6 +244,14 @@ class PlatformBridge { return result as bool? ?? false; } + static Future shareMultipleContentUris(List uris, {String title = ''}) async { + final result = await _channel.invokeMethod('shareMultipleContentUris', { + 'uris': uris, + 'title': title, + }); + return result as bool? ?? false; + } + static Future> fetchLyrics( String spotifyId, String trackName, From 4df96db809588e492746cc7ed467048d7323fe5b Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 18 Feb 2026 19:29:59 +0700 Subject: [PATCH 09/38] feat: batch re-enrich for local tracks, SAF FD refactor, Ogg quality fix - Replace batch Share action with batch Re-enrich in local album selection bar - Full native/FFmpeg re-enrich flow with SAF write-back support - Triggers incremental local library scan after completion to refresh metadata - Queue tab: switch first selection action to Re-enrich when all selected items are local-only - Refactor SAF FD handoff in MainActivity: drop detachFd/dup pattern, pass procfs path to Go and let Go re-open it to avoid fdsan double-close race conditions - Handle /proc/self/fd/ path in output_fd.go: re-open via O_WRONLY|O_TRUNC instead of taking raw FD ownership - Fix Ogg duration/bitrate calculation in audio_metadata.go: - Use float64 arithmetic and math.Round for accurate duration - Compute bitrate from file size / float duration at the source - Validate Ogg page header fields (version, headerType, segment table) to avoid false positives from payload bytes during backward scan - Guard against corrupted granule values (>24h duration, <8kbps bitrate) - Rename trackReEnrich label from 'Re-enrich Metadata' to 'Re-enrich' across all 13 locales and ARB files - Update CHANGELOG.md with 3.7.0 entry --- CHANGELOG.md | 28 ++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 22 +- go_backend/audio_metadata.go | 63 ++- go_backend/output_fd.go | 7 + lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_es.dart | 4 +- lib/l10n/app_localizations_fr.dart | 2 +- lib/l10n/app_localizations_hi.dart | 2 +- lib/l10n/app_localizations_id.dart | 2 +- lib/l10n/app_localizations_ja.dart | 2 +- lib/l10n/app_localizations_ko.dart | 2 +- lib/l10n/app_localizations_nl.dart | 2 +- lib/l10n/app_localizations_pt.dart | 4 +- lib/l10n/app_localizations_ru.dart | 2 +- lib/l10n/app_localizations_tr.dart | 2 +- lib/l10n/app_localizations_zh.dart | 6 +- lib/l10n/arb/app_de.arb | 2 +- lib/l10n/arb/app_en.arb | 2 +- lib/l10n/arb/app_es_ES.arb | 2 +- lib/l10n/arb/app_fr.arb | 2 +- lib/l10n/arb/app_hi.arb | 2 +- lib/l10n/arb/app_id.arb | 2 +- lib/l10n/arb/app_ja.arb | 2 +- lib/l10n/arb/app_ko.arb | 2 +- lib/l10n/arb/app_nl.arb | 2 +- lib/l10n/arb/app_pt_PT.arb | 2 +- lib/l10n/arb/app_ru.arb | 2 +- lib/l10n/arb/app_tr.arb | 2 +- lib/l10n/arb/app_zh_CN.arb | 2 +- lib/l10n/arb/app_zh_TW.arb | 2 +- lib/screens/downloaded_album_screen.dart | 107 +++-- lib/screens/local_album_screen.dart | 362 ++++++++++++++--- lib/screens/queue_tab.dart | 368 ++++++++++++++++-- lib/screens/settings/donate_page.dart | 14 +- 36 files changed, 843 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8490b61e..f149e1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [3.7.0] - 2026-02-18 + +### Added + +- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar + - Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent + - Supports regular file paths via SharePlus + - Available in Downloaded Album, Local Album, and Queue tab screens +- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation + - Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection + - Full SAF support: copies to temp, converts, writes back, deletes original, updates history + - Progress and result snackbar feedback during conversion + - Available in Downloaded Album, Local Album, and Queue tab screens +- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs +- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`) + +### Changed + +- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich` + - Local album selection bar now uses `Re-enrich` + `Convert` actions + - Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow) + - After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately +- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only + - If selection contains downloaded or mixed items, action remains `Share` + - Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion + +--- + ## [3.6.9] - 2026-02-17 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index be542a83..83caa3be 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build -import android.os.ParcelFileDescriptor import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode @@ -666,22 +665,12 @@ class MainActivity: FlutterFragmentActivity() { val pfd = contentResolver.openFileDescriptor(document.uri, "rw") ?: return errorJson("Failed to open SAF file") - var fdHandedOffToGo = false try { - // Keep the original PFD open so the document provider receives close signaling. - // Pass a duplicated FD to Go and detach only the duplicate. - val writerPfd = ParcelFileDescriptor.dup(pfd.fileDescriptor) - val detachedFd = writerPfd.detachFd() - try { - writerPfd.close() - } catch (_: Exception) {} - - // After detach, ownership is intended for Go. Kotlin must never close this FD, - // otherwise Android fdsan may abort on double-close during cancellation races. - fdHandedOffToGo = true - req.put("output_path", "/proc/self/fd/$detachedFd") - req.put("output_fd", detachedFd) + // Keep SAF PFD ownership in Kotlin and pass only procfs path to Go. + // Go re-opens this procfs FD path for writing to avoid raw FD ownership handoff. + req.put("output_path", "/proc/self/fd/${pfd.fd}") + req.put("output_fd", 0) req.put("output_ext", outputExt) val response = downloader(req.toString()) val respObj = JSONObject(response) @@ -696,9 +685,6 @@ class MainActivity: FlutterFragmentActivity() { document.delete() return errorJson("SAF download failed: ${e.message}") } finally { - if (!fdHandedOffToGo) { - android.util.Log.w("SpotiFLAC", "SAF writer FD was not handed off to Go") - } try { pfd.close() } catch (_: Exception) {} diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 86112467..4ccfc627 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "io" + "math" "os" "path/filepath" "strconv" @@ -1127,17 +1128,33 @@ func GetOggQuality(filePath string) (*OggQuality, error) { // Opus always uses 48kHz granule position internally totalSamples := granule - int64(preSkip) if totalSamples > 0 { - quality.Duration = int(totalSamples / 48000) + durationSec := float64(totalSamples) / 48000.0 + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } } } else if quality.SampleRate > 0 { - quality.Duration = int(granule / int64(quality.SampleRate)) + durationSec := float64(granule) / float64(quality.SampleRate) + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } } } - // Calculate average bitrate from file size and actual duration - if quality.Duration > 0 { + // Fallback bitrate estimate if duration exists but bitrate couldn't be derived. + if quality.Bitrate <= 0 && quality.Duration > 0 { quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) } + // Guard against obviously invalid values from corrupted/unreliable granule reads. + if quality.Duration > 24*60*60 { + quality.Duration = 0 + quality.Bitrate = 0 + } + if quality.Bitrate > 0 && quality.Bitrate < 8000 { + quality.Bitrate = 0 + } return quality, nil } @@ -1162,21 +1179,35 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { } buf = buf[:n] - // Scan backwards for "OggS" magic - lastPageOffset := -1 for i := n - 4; i >= 0; i-- { - if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' { - lastPageOffset = i - break + if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' { + continue } + if i+27 > n { + continue + } + // Validate minimal header fields to avoid false positives inside payload bytes. + version := buf[i+4] + headerType := buf[i+5] + if version != 0 || headerType > 0x07 { + continue + } + segmentCount := int(buf[i+26]) + headerLen := 27 + segmentCount + if i+headerLen > n { + continue + } + payloadLen := 0 + for s := 0; s < segmentCount; s++ { + payloadLen += int(buf[i+27+s]) + } + if i+headerLen+payloadLen > n { + continue + } + // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64). + return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14])) } - - if lastPageOffset < 0 || lastPageOffset+14 > n { - return 0 - } - - // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64) - return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14])) + return 0 } // ============================================================================= diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index 53a2bd3f..248e28fd 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -14,6 +14,13 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { if isFDOutput(outputFD) { return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil } + + path := strings.TrimSpace(outputPath) + if strings.HasPrefix(path, "/proc/self/fd/") { + // Re-open procfs fd path instead of taking ownership of raw detached fd. + return os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + } + return os.Create(outputPath) } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 195b2f38..847fd92b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5125,7 +5125,7 @@ abstract class AppLocalizations { /// Menu action - re-embed metadata into audio file /// /// In en, this message translates to: - /// **'Re-enrich Metadata'** + /// **'Re-enrich'** String get trackReEnrich; /// Subtitle for re-enrich metadata action diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 51d9f9b2..8bfc7e07 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2907,7 +2907,7 @@ class AppLocalizationsDe extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 444ac354..91db54f6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsEn extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index bf82005a..cfc8e46a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsEs extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -5908,7 +5908,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b5f1a283..f1354665 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2892,7 +2892,7 @@ class AppLocalizationsFr extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index d18df768..bb4ad22a 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsHi extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a5205b83..561eff10 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2899,7 +2899,7 @@ class AppLocalizationsId extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 82fd113e..2e806040 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2872,7 +2872,7 @@ class AppLocalizationsJa extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f26d0895..d0909a4d 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2885,7 +2885,7 @@ class AppLocalizationsKo extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 397122f8..6de6d29d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsNl extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 442e8f1b..e39e5e64 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsPt extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -5902,7 +5902,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e3d6a1d8..95b1295b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2983,7 +2983,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 0c14cf80..15e12f36 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2901,7 +2901,7 @@ class AppLocalizationsTr extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2d4a94c4..f88c9131 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsZh extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -5875,7 +5875,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -8808,7 +8808,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 9f356f1b..e8dba80d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7a6dd20c..7853c4fe 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2187,7 +2187,7 @@ "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, "trackSaveLyricsProgress": "Saving lyrics...", "@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"}, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", "@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"}, diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 4e0dfd9b..5643ebe2 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 41685440..41034185 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 8f6ab38f..71d38aab 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 16a9f483..8e2d9214 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3809,7 +3809,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index fd193b3c..cef5e33a 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 9d982e1a..5627cd07 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 8bae60f8..e331bf27 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 2caeebcb..75810ad5 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 2025e802..f29925ea 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 877cefc2..e51150d8 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index f200ce55..f55e8b80 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 168506b3..954569ce 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 8faa85a7..12714ac6 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1019,7 +1019,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { width: 40, height: 4, decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), borderRadius: BorderRadius.circular(2), ), ), @@ -1051,8 +1053,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - selectedBitrate = - format == 'Opus' ? '128k' : '320k'; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; }); } }, @@ -1102,7 +1105,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), child: Text( - context.l10n.selectionConvertCount(_selectedIds.length), + context.l10n.selectionConvertCount( + _selectedIds.length, + ), ), ), ), @@ -1127,12 +1132,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { final item = tracksById[id]; if (item == null) continue; // For SAF items, use safFileName to detect format (filePath is content:// URI) - final nameToCheck = (item.safFileName != null && item.safFileName!.isNotEmpty) + final nameToCheck = + (item.safFileName != null && item.safFileName!.isNotEmpty) ? item.safFileName!.toLowerCase() : item.filePath.toLowerCase(); - final ext = nameToCheck.endsWith('.flac') ? 'FLAC' - : nameToCheck.endsWith('.mp3') ? 'MP3' - : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' : null; if (ext != null && ext != targetFormat) selected.add(item); } @@ -1152,7 +1161,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( context.l10n.selectionBatchConvertConfirmMessage( - selected.length, targetFormat, bitrate, + selected.length, + targetFormat, + bitrate, ), ), actions: [ @@ -1173,6 +1184,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final historyDb = HistoryDatabase.instance; + final newQuality = + '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -1181,7 +1194,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.selectionBatchConvertProgress(i + 1, total)), + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), duration: const Duration(seconds: 30), ), ); @@ -1210,7 +1225,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { final coverOutput = '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, coverOutput, + item.filePath, + coverOutput, ); if (coverResult['error'] == null) coverPath = coverOutput; } catch (_) {} @@ -1220,7 +1236,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); if (safTempPath == null) continue; workingPath = safTempPath; } @@ -1235,12 +1253,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); if (coverPath != null) { - try { await File(coverPath).delete(); } catch (_) {} + try { + await File(coverPath).delete(); + } catch (_) {} } if (newPath == null) { if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } @@ -1251,10 +1273,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (treeUri != null && treeUri.isNotEmpty) { final oldFileName = item.safFileName ?? ''; final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -1265,22 +1293,43 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); if (safUri == null || safUri.isEmpty) { - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } - try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} - await historyDb.updateFilePath(item.id, safUri, newSafFileName: newFileName); + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + await historyDb.updateFilePath( + item.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, + ); } - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } } else { - await historyDb.updateFilePath(item.id, newPath); + await historyDb.updateFilePath( + item.id, + newPath, + newQuality: newQuality, + clearAudioSpecs: true, + ); } successCount++; @@ -1295,7 +1344,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat), + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), ), ), ); @@ -1354,7 +1407,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.downloadedAlbumSelectedCount(selectedCount), + context.l10n.downloadedAlbumSelectedCount( + selectedCount, + ), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 5d654943..aca09bfc 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -3,9 +3,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -578,7 +578,11 @@ class _LocalAlbumScreenState extends ConsumerState { } // For lossless formats, use bit depth / sample rate - if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null; + if (first.bitDepth == null || + first.bitDepth == 0 || + first.sampleRate == null) { + return null; + } final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; @@ -824,47 +828,246 @@ class _LocalAlbumScreenState extends ConsumerState { ); } - /// Share selected local tracks - Future _shareSelected(List allTracks) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final safUris = []; - final filesToShare = []; + bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - final path = item.filePath; - if (isContentUri(path)) { - if (await fileExists(path)) safUris.add(path); - } else if (await fileExists(path)) { - filesToShare.add(XFile(path)); + Future _safeDeleteFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); } - } + } catch (_) {} + } - if (safUris.isEmpty && filesToShare.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionShareNoFiles)), - ); + Future _cleanupTempFileAndParent(String path) async { + await _safeDeleteFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); } - return; - } + } catch (_) {} + } - // Share SAF content URIs via native intent - if (safUris.isNotEmpty) { + Future _applyFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasValue(effectiveCoverPath)) { try { - if (safUris.length == 1) { - await PlatformBridge.shareContentUri(safUris.first); + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; } else { - await PlatformBridge.shareMultipleContentUris(safUris); + try { + await tempDir.delete(recursive: true); + } catch (_) {} } } catch (_) {} } - // Share regular files via SharePlus - if (filesToShare.isNotEmpty) { - await SharePlus.instance.share(ShareParams(files: filesToShare)); + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); } + + if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + await _safeDeleteFile(tempPath!); + return false; + } + } + + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + if (_hasValue(tempPath)) { + await _safeDeleteFile(tempPath!); + } + + return ffmpegResult != null; + } + + Future _reEnrichLocalTrack(LocalLibraryItem item) async { + final durationMs = (item.duration ?? 0) * 1000; + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? item.artistName, + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + return true; + } + if (method == 'ffmpeg') { + return _applyFfmpegReEnrichResult(item, result); + } + return false; + } + + /// Batch re-enrich selected local tracks + Future _reEnrichSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item != null) { + selected.add(item); + } + } + + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.trackReEnrich), + content: Text( + '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' + '${context.l10n.downloadedAlbumSelectedCount(selected.length)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackReEnrich), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + var successCount = 0; + final total = selected.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final ok = await _reEnrichLocalTrack(item); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan(localLibraryPath); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); } /// Show batch convert bottom sheet @@ -899,7 +1102,9 @@ class _LocalAlbumScreenState extends ConsumerState { width: 40, height: 4, decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), borderRadius: BorderRadius.circular(2), ), ), @@ -931,8 +1136,9 @@ class _LocalAlbumScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - selectedBitrate = - format == 'Opus' ? '128k' : '320k'; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; }); } }, @@ -982,7 +1188,9 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), child: Text( - context.l10n.selectionConvertCount(_selectedIds.length), + context.l10n.selectionConvertCount( + _selectedIds.length, + ), ), ), ), @@ -1050,7 +1258,9 @@ class _LocalAlbumScreenState extends ConsumerState { title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( context.l10n.selectionBatchConvertConfirmMessage( - selected.length, targetFormat, bitrate, + selected.length, + targetFormat, + bitrate, ), ), actions: [ @@ -1079,7 +1289,9 @@ class _LocalAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.selectionBatchConvertProgress(i + 1, total)), + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), duration: const Duration(seconds: 30), ), ); @@ -1108,7 +1320,8 @@ class _LocalAlbumScreenState extends ConsumerState { final coverOutput = '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, coverOutput, + item.filePath, + coverOutput, ); if (coverResult['error'] == null) coverPath = coverOutput; } catch (_) {} @@ -1119,7 +1332,9 @@ class _LocalAlbumScreenState extends ConsumerState { if (isSaf) { // Copy SAF file to temp for conversion - safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); if (safTempPath == null) continue; workingPath = safTempPath; } @@ -1134,12 +1349,16 @@ class _LocalAlbumScreenState extends ConsumerState { ); if (coverPath != null) { - try { await File(coverPath).delete(); } catch (_) {} + try { + await File(coverPath).delete(); + } catch (_) {} } if (newPath == null) { if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } @@ -1165,7 +1384,8 @@ class _LocalAlbumScreenState extends ConsumerState { final docIdx = pathSegments.indexOf('document'); if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { final treeId = pathSegments[treeIdx + 1]; - treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; } if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { @@ -1179,9 +1399,13 @@ class _LocalAlbumScreenState extends ConsumerState { : ''; if (treeId.isNotEmpty && docPath.startsWith(treeId)) { final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; } } else { oldFileName = docPath; @@ -1190,10 +1414,16 @@ class _LocalAlbumScreenState extends ConsumerState { if (treeUri != null && oldFileName.isNotEmpty) { final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -1204,22 +1434,32 @@ class _LocalAlbumScreenState extends ConsumerState { ); if (safUri == null || safUri.isEmpty) { - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } // Delete old SAF file - try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} await localDb.deleteByPath(item.filePath); } // Clean up temp files - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } } else { // Regular file: just remove old entry, rescan will find the new one @@ -1239,7 +1479,11 @@ class _LocalAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat), + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), ), ), ); @@ -1298,7 +1542,9 @@ class _LocalAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.downloadedAlbumSelectedCount(selectedCount), + context.l10n.downloadedAlbumSelectedCount( + selectedCount, + ), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), @@ -1337,15 +1583,15 @@ class _LocalAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Action buttons row: Share, Convert + // Action buttons row: Re-enrich, Convert Row( children: [ Expanded( child: _LocalAlbumSelectionActionButton( - icon: Icons.share_outlined, - label: context.l10n.selectionShareCount(selectedCount), + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', onPressed: selectedCount > 0 - ? () => _shareSelected(tracks) + ? () => _reEnrichSelected(tracks) : null, colorScheme: colorScheme, ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 7f1b40f4..6dc317f7 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -79,7 +79,9 @@ class UnifiedLibraryItem { // Lossy format with bitrate final fmt = item.format?.toUpperCase() ?? ''; quality = '$fmt ${item.bitrate}kbps'.trim(); - } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { + } else if (item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null) { // Lossless format with actual bit depth quality = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; @@ -910,7 +912,9 @@ class _QueueTabState extends ConsumerState { if (item.bitrate != null && item.bitrate! > 0) { return '${item.bitrate}kbps'; } - if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { + if (item.bitDepth == null || + item.bitDepth == 0 || + item.sampleRate == null) { return null; } return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; @@ -2927,6 +2931,263 @@ class _QueueTabState extends ConsumerState { ); } + bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty; + + List _selectedItemsFromAll( + List allItems, + ) { + final itemsById = {for (final item in allItems) item.id: item}; + return _selectedIds + .map((id) => itemsById[id]) + .whereType() + .toList(growable: false); + } + + bool _isLocalOnlySelection(List allItems) { + final selectedItems = _selectedItemsFromAll(allItems); + return selectedItems.isNotEmpty && + selectedItems.every((item) => item.localItem != null); + } + + Future _safeDeleteTempFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + Future _cleanupTempFileAndParentDir(String path) async { + await _safeDeleteTempFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); + } + } catch (_) {} + } + + Future _applyQueueFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasTextValue(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } + + if (ffmpegResult != null && + _hasTextValue(tempPath) && + _hasTextValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + await _safeDeleteTempFile(tempPath!); + return false; + } + } + + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + if (_hasTextValue(tempPath)) { + await _safeDeleteTempFile(tempPath!); + } + + return ffmpegResult != null; + } + + Future _reEnrichQueueLocalTrack(LocalLibraryItem item) async { + final durationMs = (item.duration ?? 0) * 1000; + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? item.artistName, + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + return true; + } + if (method == 'ffmpeg') { + return _applyQueueFfmpegReEnrichResult(item, result); + } + return false; + } + + Future _reEnrichSelectedLocalFromQueue( + List allItems, + ) async { + final selectedItems = _selectedItemsFromAll(allItems); + final selectedLocalItems = selectedItems + .map((item) => item.localItem) + .whereType() + .toList(growable: false); + + if (selectedLocalItems.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.trackReEnrich), + content: Text( + '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' + '${context.l10n.downloadedAlbumSelectedCount(selectedLocalItems.length)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackReEnrich), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + var successCount = 0; + final total = selectedLocalItems.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + final item = selectedLocalItems[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final ok = await _reEnrichQueueLocalTrack(item); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan(localLibraryPath); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + } + /// Share selected tracks via system share sheet Future _shareSelected(List allItems) async { final itemsById = {for (final item in allItems) item.id: item}; @@ -2966,9 +3227,7 @@ class _QueueTabState extends ConsumerState { // Share regular files via SharePlus if (filesToShare.isNotEmpty) { - await SharePlus.instance.share( - ShareParams(files: filesToShare), - ); + await SharePlus.instance.share(ShareParams(files: filesToShare)); } } @@ -3038,8 +3297,9 @@ class _QueueTabState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - selectedBitrate = - format == 'Opus' ? '128k' : '320k'; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; }); } }, @@ -3132,10 +3392,10 @@ class _QueueTabState extends ConsumerState { final ext = nameToCheck.endsWith('.flac') ? 'FLAC' : nameToCheck.endsWith('.mp3') - ? 'MP3' - : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) - ? 'Opus' - : null; + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; if (ext != null && ext != targetFormat) { selectedItems.add(item); } @@ -3144,9 +3404,7 @@ class _QueueTabState extends ConsumerState { if (selectedItems.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.selectionConvertNoConvertible), - ), + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), ); } return; @@ -3182,6 +3440,8 @@ class _QueueTabState extends ConsumerState { int successCount = 0; final total = selectedItems.length; final historyDb = HistoryDatabase.instance; + final newQuality = + '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -3205,8 +3465,7 @@ class _QueueTabState extends ConsumerState { 'ALBUM': item.albumName, }; try { - final result = - await PlatformBridge.readFileMetadata(item.filePath); + final result = await PlatformBridge.readFileMetadata(item.filePath); if (result['error'] == null) { result.forEach((key, value) { if (key == 'error' || value == null) return; @@ -3238,8 +3497,9 @@ class _QueueTabState extends ConsumerState { String? safTempPath; if (isSaf) { - safTempPath = - await PlatformBridge.copyContentUriToTemp(item.filePath); + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); if (safTempPath == null) continue; workingPath = safTempPath; } @@ -3278,10 +3538,12 @@ class _QueueTabState extends ConsumerState { if (treeUri != null && treeUri.isNotEmpty) { final oldFileName = hi.safFileName ?? ''; final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = - dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = - targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' @@ -3317,6 +3579,8 @@ class _QueueTabState extends ConsumerState { hi.id, safUri, newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, ); } // Cleanup temp files @@ -3341,7 +3605,8 @@ class _QueueTabState extends ConsumerState { final docIdx = pathSegments.indexOf('document'); if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { final treeId = pathSegments[treeIdx + 1]; - treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; } if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); @@ -3353,9 +3618,13 @@ class _QueueTabState extends ConsumerState { : ''; if (treeId.isNotEmpty && docPath.startsWith(treeId)) { final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; } } else { oldFileName = docPath; @@ -3364,10 +3633,16 @@ class _QueueTabState extends ConsumerState { if (treeUri != null && oldFileName.isNotEmpty) { final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -3378,27 +3653,39 @@ class _QueueTabState extends ConsumerState { ); if (safUri == null || safUri.isEmpty) { - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } - try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} await LibraryDatabase.instance.deleteByPath(item.filePath); } // Cleanup temp files - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } } else if (item.historyItem != null) { // Regular file - update history path await historyDb.updateFilePath( item.historyItem!.id, newPath, + newQuality: newQuality, + clearAudioSpecs: true, ); } else if (item.localItem != null) { // Regular local library file - delete old db entry, rescan picks up new file @@ -3443,6 +3730,7 @@ class _QueueTabState extends ConsumerState { final selectedCount = _selectedIds.length; final allSelected = selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; + final localOnlySelection = _isLocalOnlySelection(unifiedItems); return Container( decoration: BoxDecoration( @@ -3526,15 +3814,21 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), - // Action buttons row: Share, Convert, Delete + // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ Expanded( child: _SelectionActionButton( - icon: Icons.share_outlined, - label: context.l10n.selectionShareCount(selectedCount), + icon: localOnlySelection + ? Icons.auto_fix_high_outlined + : Icons.share_outlined, + label: localOnlySelection + ? '${context.l10n.trackReEnrich} ($selectedCount)' + : context.l10n.selectionShareCount(selectedCount), onPressed: selectedCount > 0 - ? () => _shareSelected(unifiedItems) + ? () => localOnlySelection + ? _reEnrichSelectedLocalFromQueue(unifiedItems) + : _shareSelected(unifiedItems) : null, colorScheme: colorScheme, ), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 412f8701..44944d49 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -174,7 +174,6 @@ class _RecentDonorsCard extends StatelessWidget { 'laflame', 'Elias el Autentico', 'Faylyne', - 'Jul', ]; // Match SettingsGroup color logic @@ -363,7 +362,8 @@ int _cr(String v) { for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } return r; } -const _cv = {998370}; +// Highlighted supporters (hashes of names): Julian, J. +const _cv = {1825257268, 1035}; class _SupporterChip extends StatelessWidget { final String name; @@ -374,15 +374,19 @@ class _SupporterChip extends StatelessWidget { @override Widget build(BuildContext context) { final e = _cv.contains(_cr(name)); + const goldChipColor = Color(0xFFFFF8DC); + const goldAccentColor = Color(0xFFB8860B); + const goldDarkChipColor = Color(0xFF3A3000); + final chipColor = e - ? const Color(0xFFFFF3E0) + ? goldChipColor : colorScheme.secondaryContainer; final accentColor = e - ? const Color(0xFFFF8F00) + ? goldAccentColor : colorScheme.primary; final isDark = Theme.of(context).brightness == Brightness.dark; final effectiveChipColor = e && isDark - ? const Color(0xFF3E2723) + ? goldDarkChipColor : chipColor; return Material( From 5161ac8f774e692a52264d6d5bb95f8ac9964cfd Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 18 Feb 2026 19:43:50 +0700 Subject: [PATCH 10/38] chore: bump version to 3.7.0+83 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 9a2e08a7..a8639cc8 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.6.9'; - static const String buildNumber = '82'; + static const String version = '3.7.0'; + static const String buildNumber = '83'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index a74f25d1..61840f87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.6.9+82 +version: 3.7.0+83 environment: sdk: ^3.10.0 From caf68c813734691c7906233997eff90cc51e50e4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 00:28:12 +0700 Subject: [PATCH 11/38] redesign: full-screen cover art with parallax scroll across all detail screens Replace blurred background + centered cover thumbnail with full-screen cover art, dark gradient overlay, and parallax collapse mode for a consistent Apple Music-inspired design across album, playlist, downloaded album, local album, and track metadata screens. Remove select button UI (users enter selection via long-press), upgrade cover resolution for Spotify/Deezer CDN, and move track/album info into the overlay. --- lib/screens/album_screen.dart | 393 ++++++++++------------ lib/screens/artist_screen.dart | 97 +++--- lib/screens/downloaded_album_screen.dart | 361 ++++++++------------ lib/screens/local_album_screen.dart | 381 ++++++++------------- lib/screens/playlist_screen.dart | 282 +++++++--------- lib/screens/track_metadata_screen.dart | 411 +++++++++++------------ 6 files changed, 821 insertions(+), 1104 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8224c2b5..e9e756fc 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -117,12 +115,38 @@ class _AlbumScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + + /// Upgrade cover URL to a reasonable resolution for full-screen display. + /// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate). + /// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800). + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000) + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + String _formatReleaseDate(String date) { if (date.length >= 10) { final parts = date.substring(0, 10).split('-'); @@ -223,7 +247,6 @@ class _AlbumScreenState extends ConsumerState { ), ), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ - _buildTrackListHeader(context, colorScheme), _buildTrackList(context, colorScheme, tracks), ], const SliverToBoxAdapter(child: SizedBox(height: 32)), @@ -233,14 +256,10 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); + final tracks = _tracks ?? []; + final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; return SliverAppBar( expandedHeight: expandedHeight, @@ -268,25 +287,17 @@ class _AlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final backgroundMemCacheWidth = (constraints.maxWidth * dpr) - .round() - .clamp(720, 1440) - .toInt(); return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.parallax, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background (no blur, full resolution) if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: widget.coverUrl!, + imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -294,80 +305,167 @@ class _AlbumScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Album info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.albumName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: widget.coverUrl != null - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + if (artistName != null && artistName.isNotEmpty) ...[ + const SizedBox(height: 6), + GestureDetector( + onTap: () => + _navigateToArtist(context, artistName), + child: Text( + artistName, + style: TextStyle( + color: colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + if (tracks.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.music_note, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(tracks.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (releaseDate != null && + releaseDate.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: + Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.calendar_today, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + _formatReleaseDate(releaseDate), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], ), ), - ), - ), + ], + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + ), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ], + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -375,10 +473,10 @@ class _AlbumScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -386,151 +484,8 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - final tracks = _tracks ?? []; - final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; - final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; - - 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.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - if (artistName != null && artistName.isNotEmpty) ...[ - const SizedBox(height: 4), - GestureDetector( - onTap: () => _navigateToArtist(context, artistName), - child: Text( - artistName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.primary, - ), - ), - ), - ], - const SizedBox(height: 12), - if (tracks.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 14, - color: colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(tracks.length), - style: TextStyle( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - if (releaseDate != null && releaseDate.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.calendar_today, - size: 14, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - _formatReleaseDate(releaseDate), - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - if (tracks.isNotEmpty) ...[ - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.downloadAllCount(tracks.length)), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), - ], - ], - ), - ), - ), - ), - ); - } - - Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.tracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } Widget _buildTrackList( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 97737649..d825ac3e 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1100,63 +1100,68 @@ class _ArtistScreenState extends ConsumerState { left: 16, right: 16, bottom: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, 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), + Expanded( + 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, ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - 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), + 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), + ), + ], + ), ), ], - ), + ], ), - ], - // Download Discography button + ), + // Download Discography button (icon only, right-aligned) if (hasDiscography && !_isSelectionMode) ...[ - const SizedBox(height: 12), - SizedBox( - height: 40, - child: FilledButton.icon( + const SizedBox(width: 12), + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: IconButton( onPressed: () => _showDiscographyOptions( context, colorScheme, albums, ), - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.discographyDownload), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - padding: const EdgeInsets.symmetric(horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), + icon: const Icon(Icons.download_rounded, size: 26), + color: Colors.black87, + tooltip: context.l10n.discographyDownload, ), ), ], diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 12714ac6..23892a9e 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -79,12 +78,34 @@ class _DownloadedAlbumScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + + /// Upgrade cover URL to a reasonable resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + /// Get tracks for this album from history provider (reactive) List _getAlbumTracks( List allItems, @@ -359,7 +380,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { slivers: [ _buildAppBar(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks), - _buildTrackListHeader(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks), SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 120 : 32), @@ -412,22 +432,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); + final commonQuality = _getCommonQuality(tracks); return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: - colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -449,33 +462,24 @@ class _DownloadedAlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final backgroundMemCacheWidth = (constraints.maxWidth * dpr) - .round() - .clamp(720, 1440) - .toInt(); return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.parallax, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background if (embeddedCoverPath != null) Image.file( File(embeddedCoverPath), fit: BoxFit.cover, - cacheWidth: backgroundMemCacheWidth, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: widget.coverUrl!, + imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -483,96 +487,136 @@ class _DownloadedAlbumScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - // Cover image centered - fade out when collapsing - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Album info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.albumName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).toInt(), - cacheHeight: (coverSize * 2).toInt(), - errorBuilder: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 6), + Text( + widget.artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (tracks.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.download_done, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.downloadedAlbumDownloadedCount( + tracks.length, + ), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (commonQuality != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + commonQuality, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), - ) - : widget.coverUrl != null - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, - ), ), - ), - ), + ], + ), + ], + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -580,10 +624,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -595,102 +639,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final commonQuality = _getCommonQuality(tracks); - - 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.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - widget.artistName, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - 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.download_done, - size: 14, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Text( - context.l10n.downloadedAlbumDownloadedCount( - tracks.length, - ), - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - if (commonQuality != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: commonQuality.startsWith('24') - ? colorScheme.tertiaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - commonQuality, - style: TextStyle( - color: commonQuality.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } String? _getCommonQuality(List tracks) { @@ -721,43 +671,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return firstQuality; } - Widget _buildTrackListHeader( - BuildContext context, - ColorScheme colorScheme, - List tracks, - ) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.downloadedAlbumTracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - if (!_isSelectionMode) - TextButton.icon( - onPressed: tracks.isNotEmpty - ? () => _enterSelectionMode(tracks.first.id) - : null, - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ); - } - Widget _buildTrackList( BuildContext context, ColorScheme colorScheme, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index aca09bfc..e144882f 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -66,12 +65,18 @@ class _LocalAlbumScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + List _buildSortedTracks() { final tracks = List.from(widget.tracks); tracks.sort((a, b) { @@ -248,7 +253,6 @@ class _LocalAlbumScreenState extends ConsumerState { slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme, tracks), - _buildTrackListHeader(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks), SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 120 : 32), @@ -276,14 +280,8 @@ class _LocalAlbumScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); + final commonQuality = _commonQualityCache; return SliverAppBar( expandedHeight: expandedHeight, @@ -313,11 +311,11 @@ class _LocalAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.parallax, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background if (widget.coverPath != null) Image.file( File(widget.coverPath!), @@ -326,90 +324,161 @@ class _LocalAlbumScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - // Cover image centered - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Album info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.albumName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: widget.coverPath != null - ? Image.file( - File(widget.coverPath!), - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).toInt(), - errorBuilder: (context, error, stackTrace) => - Container( - color: - colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 6), + Text( + widget.artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.folder, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + const Text( + 'Local', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.music_note, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + '${_sortedTracksCache.length} tracks', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (commonQuality != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + commonQuality, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), + ), + ], ), - ), + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -417,10 +486,10 @@ class _LocalAlbumScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -432,133 +501,8 @@ class _LocalAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final commonQuality = _commonQualityCache; - - 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.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - widget.artistName, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - // "Local" badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder, - size: 14, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - 'Local', - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Track count - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${tracks.length} tracks', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Quality badge if all tracks have the same quality - if (commonQuality != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: commonQuality.contains('24') - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - commonQuality, - style: TextStyle( - color: commonQuality.contains('24') - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } String? _computeCommonQuality(List tracks) { @@ -595,43 +539,6 @@ class _LocalAlbumScreenState extends ConsumerState { return firstQuality; } - Widget _buildTrackListHeader( - BuildContext context, - ColorScheme colorScheme, - List tracks, - ) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.downloadedAlbumTracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - if (!_isSelectionMode) - TextButton.icon( - onPressed: tracks.isNotEmpty - ? () => _enterSelectionMode(tracks.first.id) - : null, - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ); - } - Widget _buildTrackList( BuildContext context, ColorScheme colorScheme, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index ef7d488b..97de41dc 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -120,12 +118,36 @@ class _PlaylistScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + + /// Upgrade cover URL to a reasonable resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 only + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -136,7 +158,6 @@ class _PlaylistScreenState extends ConsumerState { slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), - _buildTrackListHeader(context, colorScheme), _buildTrackList(context, colorScheme), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], @@ -145,21 +166,13 @@ class _PlaylistScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: - colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -181,25 +194,17 @@ class _PlaylistScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final backgroundMemCacheWidth = (constraints.maxWidth * dpr) - .round() - .clamp(720, 1440) - .toInt(); return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.parallax, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: widget.coverUrl!, + imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -207,81 +212,110 @@ class _PlaylistScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.playlist_play, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - // Cover image centered - fade out when collapsing - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Playlist info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.playlistName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: widget.coverUrl != null - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.playlist_play, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + if (_tracks.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.playlist_play, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(_tracks.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), - ), - ), + ], + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(_tracks.length), + ), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ], + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -289,10 +323,10 @@ class _PlaylistScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -300,98 +334,8 @@ class _PlaylistScreenState extends ConsumerState { } 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.playlistName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.playlist_play, - size: 14, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(_tracks.length), - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _tracks.isEmpty - ? null - : () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.downloadAllCount(_tracks.length)), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.tracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 16d1d441..5be27f4e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -205,12 +204,18 @@ class _TrackMetadataScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + Future _checkFile() async { var filePath = _filePath; if (filePath.startsWith('EXISTS:')) { @@ -509,19 +514,17 @@ class _TrackMetadataScreenState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final screenWidth = MediaQuery.of(context).size.width; - final coverSize = screenWidth * 0.5; + final expandedHeight = _calculateExpandedHeight(context); return Scaffold( body: CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( - expandedHeight: 320, + expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: - colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -541,21 +544,18 @@ class _TrackMetadataScreenState extends ConsumerState { builder: (context, constraints) { final collapseRatio = (constraints.maxHeight - kToolbarHeight) / - (320 - kToolbarHeight); + (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.parallax, background: _buildHeaderBackground( context, colorScheme, - coverSize, + expandedHeight, showContent, ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -563,10 +563,10 @@ class _TrackMetadataScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: _popWithMetadataResult, ), @@ -575,10 +575,10 @@ class _TrackMetadataScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.more_vert, color: colorScheme.onSurface), + child: const Icon(Icons.more_vert, color: Colors.white), ), onPressed: () => _showOptionsMenu(context, ref, colorScheme), ), @@ -591,10 +591,6 @@ class _TrackMetadataScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTrackInfoCard(context, colorScheme, _fileExists), - - const SizedBox(height: 16), - _buildMetadataCard(context, colorScheme, _fileSize), const SizedBox(height: 16), @@ -627,34 +623,23 @@ class _TrackMetadataScreenState extends ConsumerState { Widget _buildHeaderBackground( BuildContext context, ColorScheme colorScheme, - double coverSize, + double expandedHeight, bool showContent, ) { - final screenSize = MediaQuery.sizeOf(context); - final pixelRatio = MediaQuery.devicePixelRatioOf(context); - final backgroundCacheWidth = (screenSize.width * pixelRatio).round(); - final backgroundCacheHeight = (screenSize.height * 0.65 * pixelRatio) - .round(); - final coverCacheSize = (coverSize * pixelRatio).round(); - return Stack( fit: StackFit.expand, children: [ - // Blurred cover art background + // Full-screen cover background if (_hasPath(_embeddedCoverPreviewPath)) Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, - cacheWidth: backgroundCacheWidth, - cacheHeight: backgroundCacheHeight, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else if (_coverUrl != null) CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundCacheWidth, - memCacheHeight: backgroundCacheHeight, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), @@ -663,113 +648,209 @@ class _TrackMetadataScreenState extends ConsumerState { Image.file( File(_localCoverPath!), fit: BoxFit.cover, - cacheWidth: backgroundCacheWidth, - cacheHeight: backgroundCacheHeight, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - - // Blur filter - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 80, + color: colorScheme.onSurfaceVariant, + ), ), - ), - - // Bottom fade to surface + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: 80, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - - // Cover art - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Hero( - tag: 'cover_$_itemId', - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: _hasPath(_embeddedCoverPreviewPath) - ? Image.file( - File(_embeddedCoverPreviewPath!), - fit: BoxFit.cover, - cacheWidth: coverCacheSize, - cacheHeight: coverCacheSize, - errorBuilder: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : _coverUrl != null - ? CachedNetworkImage( - imageUrl: _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file( - File(_localCoverPath!), - fit: BoxFit.cover, - cacheWidth: coverCacheSize, - cacheHeight: coverCacheSize, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), + // Track info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + trackName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - ), + const SizedBox(height: 6), + Text( + artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + albumName, + style: const TextStyle( + color: Colors.white54, + fontSize: 14, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + if (_quality != null && _quality!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _quality!, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatDuration(duration!), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (_service != 'local') + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _service[0].toUpperCase() + _service.substring(1), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.folder, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + const Text( + 'Local', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (!_fileExists) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning_rounded, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.trackFileNotFound, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ], ), ), ), @@ -777,94 +858,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildTrackInfoCard( - BuildContext context, - ColorScheme colorScheme, - bool fileExists, - ) { - return 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( - trackName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - - Text( - artistName, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: colorScheme.primary), - ), - const SizedBox(height: 8), - - Row( - children: [ - Icon( - Icons.album, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - albumName, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - - if (!fileExists) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.warning_rounded, - size: 16, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 6), - Text( - context.l10n.trackFileNotFound, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ], - ), - ), - ); - } - Widget _buildMetadataCard( BuildContext context, ColorScheme colorScheme, From 8e794e1ef1db5ee24e507097678f7c5fa91242fb Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 15:54:27 +0700 Subject: [PATCH 12/38] feat: Library tab redesign with playlists, drag-and-drop categorization, and pinned app bars --- CHANGELOG.md | 47 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 28 +- go_backend/exports.go | 6 + go_backend/extension_runtime.go | 50 +- go_backend/extension_store.go | 4 +- go_backend/httputil.go | 95 +- go_backend/output_fd.go | 10 +- go_backend/songlink.go | 55 +- ios/Runner/AppDelegate.swift | 7 + lib/l10n/app_localizations.dart | 246 ++++ lib/l10n/app_localizations_de.dart | 159 +++ lib/l10n/app_localizations_en.dart | 159 +++ lib/l10n/app_localizations_es.dart | 159 +++ lib/l10n/app_localizations_fr.dart | 159 +++ lib/l10n/app_localizations_hi.dart | 159 +++ lib/l10n/app_localizations_id.dart | 159 +++ lib/l10n/app_localizations_ja.dart | 159 +++ lib/l10n/app_localizations_ko.dart | 159 +++ lib/l10n/app_localizations_nl.dart | 159 +++ lib/l10n/app_localizations_pt.dart | 159 +++ lib/l10n/app_localizations_ru.dart | 159 +++ lib/l10n/app_localizations_tr.dart | 159 +++ lib/l10n/app_localizations_zh.dart | 159 +++ lib/l10n/arb/app_en.arb | 136 ++ lib/l10n/arb/app_id.arb | 231 +++- lib/models/settings.dart | 6 + lib/models/settings.g.dart | 5 + .../library_collections_provider.dart | 490 +++++++ lib/providers/settings_provider.dart | 17 + lib/screens/album_screen.dart | 188 +-- lib/screens/artist_screen.dart | 130 +- lib/screens/downloaded_album_screen.dart | 29 +- lib/screens/home_tab.dart | 135 +- lib/screens/library_playlists_screen.dart | 558 ++++++++ lib/screens/library_tracks_folder_screen.dart | 884 +++++++++++++ lib/screens/local_album_screen.dart | 17 +- lib/screens/playlist_screen.dart | 167 +-- lib/screens/queue_tab.dart | 1153 ++++++++++++++++- lib/screens/search_screen.dart | 5 +- .../settings/download_settings_page.dart | 19 +- .../settings/library_settings_page.dart | 10 +- lib/screens/track_metadata_screen.dart | 2 +- lib/services/platform_bridge.dart | 15 +- lib/utils/lyrics_metadata_helper.dart | 76 ++ lib/widgets/playlist_picker_sheet.dart | 187 +++ .../track_collection_quick_actions.dart | 254 ++++ 46 files changed, 6621 insertions(+), 708 deletions(-) create mode 100644 lib/providers/library_collections_provider.dart create mode 100644 lib/screens/library_playlists_screen.dart create mode 100644 lib/screens/library_tracks_folder_screen.dart create mode 100644 lib/utils/lyrics_metadata_helper.dart create mode 100644 lib/widgets/playlist_picker_sheet.dart create mode 100644 lib/widgets/track_collection_quick_actions.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f149e1db..439699d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,26 @@ # Changelog -## [3.7.0] - 2026-02-18 +## [3.7.0] - 2026-02-19 ### Added +- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section +- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist + - Drag feedback widget displays multi-select count badge +- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar +- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs +- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button +- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style +- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge +- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar + - Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback + - Cover options bottom sheet with change/remove actions + - Playlist list screen shows cover thumbnails +- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions +- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download +- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check +- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency +- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object - **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar - Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent - Supports regular file paths via SharePlus @@ -15,9 +32,17 @@ - Available in Downloaded Album, Local Album, and Queue tab screens - **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs - **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`) +- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`) +- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks + - Applies to backend API requests (not SongLink-only) + - Enables HTTP scheme fallback and optional insecure TLS behavior in one switch + - Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend ### Changed +- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid +- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks" +- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling - **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich` - Local album selection bar now uses `Re-enrich` + `Convert` actions - Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow) @@ -25,6 +50,26 @@ - **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only - If selection contains downloaded or mixed items, action remains `Share` - Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion +- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks +- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet +- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs) +- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded +- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only) +- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose +- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment + +### Fixed + +- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage` +- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen +- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long +- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers + - Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen + - Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics +- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert + - Reuses embedded lyrics when available + - Falls back to sidecar `.lrc` when present + - Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled --- diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 83caa3be..bd3014c5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -666,11 +666,13 @@ class MainActivity: FlutterFragmentActivity() { val pfd = contentResolver.openFileDescriptor(document.uri, "rw") ?: return errorJson("Failed to open SAF file") + var detachedFd: Int? = null try { - // Keep SAF PFD ownership in Kotlin and pass only procfs path to Go. - // Go re-opens this procfs FD path for writing to avoid raw FD ownership handoff. - req.put("output_path", "/proc/self/fd/${pfd.fd}") - req.put("output_fd", 0) + // Prefer handing off a detached FD directly to Go. + // Some devices/providers reject re-opening /proc/self/fd/* with permission denied. + detachedFd = pfd.detachFd() + req.put("output_path", "") + req.put("output_fd", detachedFd) req.put("output_ext", outputExt) val response = downloader(req.toString()) val respObj = JSONObject(response) @@ -685,9 +687,13 @@ class MainActivity: FlutterFragmentActivity() { document.delete() return errorJson("SAF download failed: ${e.message}") } finally { - try { - pfd.close() - } catch (_: Exception) {} + // If detachFd() failed before handoff, close original ParcelFileDescriptor. + // Otherwise Go owns the detached raw FD and is responsible for closing it. + if (detachedFd == null) { + try { + pfd.close() + } catch (_: Exception) {} + } } } @@ -1354,6 +1360,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions" -> { + val allowHttp = call.argument("allow_http") ?: false + val insecureTls = call.argument("insecure_tls") ?: false + withContext(Dispatchers.IO) { + Gobackend.setNetworkCompatibilityOptions(allowHttp, insecureTls) + } + result.success(null) + } "checkDuplicate" -> { val outputDir = call.argument("output_dir") ?: "" val isrc = call.argument("isrc") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index 9da92ca4..268184b3 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -140,6 +140,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { return string(jsonBytes), nil } +// SetSongLinkNetworkOptions is kept for backward compatibility. +// It now applies global network compatibility options for all backend API requests. +func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { + SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) +} + type DownloadRequest struct { ISRC string `json:"isrc"` Service string `json:"service"` diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 5ee1455e..9f87b39d 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -102,33 +102,31 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { vm: ext.VM, } - client := &http.Client{ - Timeout: 30 * time.Second, - Jar: jar, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) - return fmt.Errorf("redirect blocked: only https is allowed") - } + client := NewHTTPClientWithTimeout(30 * time.Second) + client.Jar = jar + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) + return fmt.Errorf("redirect blocked: only https is allowed") + } - domain := req.URL.Hostname() - if domain == "" { - GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID) - return fmt.Errorf("redirect blocked: hostname is required") - } - if !ext.Manifest.IsDomainAllowed(domain) { - GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) - return &RedirectBlockedError{Domain: domain} - } - if isPrivateIP(domain) { - GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) - return &RedirectBlockedError{Domain: domain, IsPrivate: true} - } - if len(via) >= 10 { - return http.ErrUseLastResponse - } - return nil - }, + domain := req.URL.Hostname() + if domain == "" { + GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID) + return fmt.Errorf("redirect blocked: hostname is required") + } + if !ext.Manifest.IsDomainAllowed(domain) { + GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain} + } + if isPrivateIP(domain) { + GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain, IsPrivate: true} + } + if len(via) >= 10 { + return http.ErrUseLastResponse + } + return nil } runtime.httpClient = client diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 27c14dec..6195907a 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -218,7 +218,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) - client := &http.Client{Timeout: 30 * time.Second} + client := NewHTTPClientWithTimeout(30 * time.Second) resp, err := client.Get(s.registryURL) if err != nil { if s.cache != nil { @@ -310,7 +310,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) - client := &http.Client{Timeout: 5 * time.Minute} + client := NewHTTPClientWithTimeout(5 * time.Minute) resp, err := client.Get(ext.getDownloadURL()) if err != nil { return fmt.Errorf("failed to download: %w", err) diff --git a/go_backend/httputil.go b/go_backend/httputil.go index d6033243..991904d0 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -11,6 +11,7 @@ import ( "net/url" "strconv" "strings" + "sync" "syscall" "time" ) @@ -37,6 +38,16 @@ const ( Second = time.Second ) +type NetworkCompatibilityOptions struct { + AllowHTTP bool + InsecureTLS bool +} + +var ( + networkCompatibilityMu sync.RWMutex + networkCompatibilityOptions NetworkCompatibilityOptions +) + var sharedTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -77,18 +88,18 @@ var metadataTransport = &http.Transport{ } var sharedClient = &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: DefaultTimeout, } var downloadClient = &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: DownloadTimeout, } func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { return &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: timeout, } } @@ -97,7 +108,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { // Use this for API calls that should not be affected by download traffic. func NewMetadataHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ - Transport: metadataTransport, + Transport: newCompatibilityTransport(metadataTransport), Timeout: timeout, } } @@ -115,12 +126,78 @@ func CloseIdleConnections() { metadataTransport.CloseIdleConnections() } +func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) { + networkCompatibilityMu.Lock() + networkCompatibilityOptions = NetworkCompatibilityOptions{ + AllowHTTP: allowHTTP, + InsecureTLS: insecureTLS, + } + networkCompatibilityMu.Unlock() + + applyTLSCompatibility(sharedTransport, insecureTLS) + applyTLSCompatibility(metadataTransport, insecureTLS) + CloseIdleConnections() + + GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS) +} + +func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions { + networkCompatibilityMu.RLock() + defer networkCompatibilityMu.RUnlock() + return networkCompatibilityOptions +} + +func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) { + if insecureTLS { + cfg := &tls.Config{InsecureSkipVerify: true} + if transport.TLSClientConfig != nil { + cfg = transport.TLSClientConfig.Clone() + cfg.InsecureSkipVerify = true + } + transport.TLSClientConfig = cfg + return + } + + transport.TLSClientConfig = nil +} + +type compatibilityTransport struct { + base http.RoundTripper +} + +func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper { + return &compatibilityTransport{base: base} +} + +func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqCompat := applyCompatibilityToRequest(req) + return t.base.RoundTrip(reqCompat) +} + +func applyCompatibilityToRequest(req *http.Request) *http.Request { + if req == nil || req.URL == nil { + return req + } + + opts := GetNetworkCompatibilityOptions() + if !opts.AllowHTTP || req.URL.Scheme != "https" { + return req + } + + reqCopy := req.Clone(req.Context()) + urlCopy := *req.URL + urlCopy.Scheme = "http" + reqCopy.URL = &urlCopy + return reqCopy +} + // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := client.Do(req) + reqToSend := applyCompatibilityToRequest(req) + resp, err := client.Do(reqToSend) if err != nil { - CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") + CheckAndLogISPBlocking(err, reqToSend.URL.String(), "HTTP") } return resp, err } @@ -145,18 +222,18 @@ func DefaultRetryConfig() RetryConfig { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { var lastErr error delay := config.InitialDelay - requestURL := req.URL.String() for attempt := 0; attempt <= config.MaxRetries; attempt++ { reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) + reqCopy = applyCompatibilityToRequest(reqCopy) resp, err := client.Do(reqCopy) if err != nil { lastErr = err - if CheckAndLogISPBlocking(err, requestURL, "HTTP") { - return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") + if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") { + return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP") } if attempt < config.MaxRetries { diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index 248e28fd..fed09bd5 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -18,7 +18,15 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { path := strings.TrimSpace(outputPath) if strings.HasPrefix(path, "/proc/self/fd/") { // Re-open procfs fd path instead of taking ownership of raw detached fd. - return os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + // Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM. + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + if err == nil { + return file, nil + } + if strings.Contains(strings.ToLower(err.Error()), "permission denied") { + return os.OpenFile(path, os.O_WRONLY, 0) + } + return nil, err } return os.Create(outputPath) diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ed9346bd..ecc9e35a 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,7 +1,6 @@ package gobackend import ( - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -46,14 +45,39 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } +func songLinkBaseURL() string { + opts := GetNetworkCompatibilityOptions() + if opts.AllowHTTP { + return "http://api.song.link/v1-alpha.1/links" + } + return "https://api.song.link/v1-alpha.1/links" +} + +func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { + apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL)) + if userCountry != "" { + apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) + } + return apiURL +} + +func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string { + apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s", + songLinkBaseURL(), + url.QueryEscape(platform), + url.QueryEscape(entityType), + url.QueryEscape(entityID)) + if userCountry != "" { + apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) + } + return apiURL +} + func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) + apiURL := buildSongLinkURLFromTarget(spotifyURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -351,11 +375,8 @@ type AlbumAvailability struct { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { songLinkRateLimiter.WaitForSlot() - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID) + apiURL := buildSongLinkURLFromTarget(spotifyURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -440,9 +461,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin songLinkRateLimiter.WaitForSlot() deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL)) + apiURL := buildSongLinkURLFromTarget(deezerURL, "US") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -546,10 +565,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit songLinkRateLimiter.WaitForSlot() - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", - url.QueryEscape(platform), - url.QueryEscape(entityType), - url.QueryEscape(entityID)) + apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "US") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -706,8 +722,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL)) + apiURL := buildSongLinkURLFromTarget(inputURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6c6a9ab6..2314d37f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -127,6 +127,13 @@ import Gobackend // Import Go framework GobackendSetDownloadDirectory(path, &error) if let error = error { throw error } return nil + + case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions": + let args = call.arguments as! [String: Any] + let allowHTTP = args["allow_http"] as? Bool ?? false + let insecureTLS = args["insecure_tls"] as? Bool ?? false + GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS) + return nil case "checkDuplicate": let args = call.arguments as! [String: Any] diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 847fd92b..8333786e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4342,6 +4342,12 @@ abstract class AppLocalizations { /// **'{count} tracks'** String libraryTracksCount(int count); + /// Unit label for tracks count (without the number itself) + /// + /// In en, this message translates to: + /// **'{count, plural, =1{track} other{tracks}}'** + String libraryTracksUnit(int count); + /// Last scan time display /// /// In en, this message translates to: @@ -5258,6 +5264,246 @@ abstract class AppLocalizations { /// **'Conversion failed'** String get trackConvertFailed; + /// Generic action button - create + /// + /// In en, this message translates to: + /// **'Create'** + String get actionCreate; + + /// Library section title for custom folders + /// + /// In en, this message translates to: + /// **'My folders'** + String get collectionFoldersTitle; + + /// Custom folder for saved tracks to download later + /// + /// In en, this message translates to: + /// **'Wishlist'** + String get collectionWishlist; + + /// Custom folder for favorite tracks + /// + /// In en, this message translates to: + /// **'Loved'** + String get collectionLoved; + + /// Custom user playlists folder + /// + /// In en, this message translates to: + /// **'Playlists'** + String get collectionPlaylists; + + /// Single playlist label + /// + /// In en, this message translates to: + /// **'Playlist'** + String get collectionPlaylist; + + /// Action to add a track to user playlist + /// + /// In en, this message translates to: + /// **'Add to playlist'** + String get collectionAddToPlaylist; + + /// Action to create a new playlist + /// + /// In en, this message translates to: + /// **'Create playlist'** + String get collectionCreatePlaylist; + + /// Empty state title when user has no playlists + /// + /// In en, this message translates to: + /// **'No playlists yet'** + String get collectionNoPlaylistsYet; + + /// Empty state subtitle when user has no playlists + /// + /// In en, this message translates to: + /// **'Create a playlist to start categorizing tracks'** + String get collectionNoPlaylistsSubtitle; + + /// Track count label for custom playlists + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 track} other{{count} tracks}}'** + String collectionPlaylistTracks(int count); + + /// Snackbar after adding track to playlist + /// + /// In en, this message translates to: + /// **'Added to \"{playlistName}\"'** + String collectionAddedToPlaylist(String playlistName); + + /// Snackbar when track already exists in playlist + /// + /// In en, this message translates to: + /// **'Already in \"{playlistName}\"'** + String collectionAlreadyInPlaylist(String playlistName); + + /// Snackbar after creating playlist + /// + /// In en, this message translates to: + /// **'Playlist created'** + String get collectionPlaylistCreated; + + /// Hint text for playlist name input + /// + /// In en, this message translates to: + /// **'Playlist name'** + String get collectionPlaylistNameHint; + + /// Validation error for empty playlist name + /// + /// In en, this message translates to: + /// **'Playlist name is required'** + String get collectionPlaylistNameRequired; + + /// Action to rename playlist + /// + /// In en, this message translates to: + /// **'Rename playlist'** + String get collectionRenamePlaylist; + + /// Action to delete playlist + /// + /// In en, this message translates to: + /// **'Delete playlist'** + String get collectionDeletePlaylist; + + /// Confirmation message for deleting playlist + /// + /// In en, this message translates to: + /// **'Delete \"{playlistName}\" and all tracks inside it?'** + String collectionDeletePlaylistMessage(String playlistName); + + /// Snackbar after deleting playlist + /// + /// In en, this message translates to: + /// **'Playlist deleted'** + String get collectionPlaylistDeleted; + + /// Snackbar after renaming playlist + /// + /// In en, this message translates to: + /// **'Playlist renamed'** + String get collectionPlaylistRenamed; + + /// Wishlist empty state title + /// + /// In en, this message translates to: + /// **'Wishlist is empty'** + String get collectionWishlistEmptyTitle; + + /// Wishlist empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap + on tracks to save what you want to download later'** + String get collectionWishlistEmptySubtitle; + + /// Loved empty state title + /// + /// In en, this message translates to: + /// **'Loved folder is empty'** + String get collectionLovedEmptyTitle; + + /// Loved empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap love on tracks to keep your favorites'** + String get collectionLovedEmptySubtitle; + + /// Playlist empty state title + /// + /// In en, this message translates to: + /// **'Playlist is empty'** + String get collectionPlaylistEmptyTitle; + + /// Playlist empty state subtitle + /// + /// In en, this message translates to: + /// **'Long-press + on any track to add it here'** + String get collectionPlaylistEmptySubtitle; + + /// Tooltip for removing track from playlist + /// + /// In en, this message translates to: + /// **'Remove from playlist'** + String get collectionRemoveFromPlaylist; + + /// Tooltip for removing track from wishlist/loved folder + /// + /// In en, this message translates to: + /// **'Remove from folder'** + String get collectionRemoveFromFolder; + + /// Snackbar after removing a track from a collection + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed'** + String collectionRemoved(String trackName); + + /// Snackbar after adding track to loved folder + /// + /// In en, this message translates to: + /// **'\"{trackName}\" added to Loved'** + String collectionAddedToLoved(String trackName); + + /// Snackbar after removing track from loved folder + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed from Loved'** + String collectionRemovedFromLoved(String trackName); + + /// Snackbar after adding track to wishlist + /// + /// In en, this message translates to: + /// **'\"{trackName}\" added to Wishlist'** + String collectionAddedToWishlist(String trackName); + + /// Snackbar after removing track from wishlist + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed from Wishlist'** + String collectionRemovedFromWishlist(String trackName); + + /// Bottom sheet action label - add track to loved folder + /// + /// In en, this message translates to: + /// **'Add to Loved'** + String get trackOptionAddToLoved; + + /// Bottom sheet action label - remove track from loved folder + /// + /// In en, this message translates to: + /// **'Remove from Loved'** + String get trackOptionRemoveFromLoved; + + /// Bottom sheet action label - add track to wishlist + /// + /// In en, this message translates to: + /// **'Add to Wishlist'** + String get trackOptionAddToWishlist; + + /// Bottom sheet action label - remove track from wishlist + /// + /// In en, this message translates to: + /// **'Remove from Wishlist'** + String get trackOptionRemoveFromWishlist; + + /// Bottom sheet action to pick a custom cover image for a playlist + /// + /// In en, this message translates to: + /// **'Change cover image'** + String get collectionPlaylistChangeCover; + + /// Bottom sheet action to remove custom cover image from a playlist + /// + /// In en, this message translates to: + /// **'Remove cover image'** + String get collectionPlaylistRemoveCover; + /// Share button text with count in selection mode /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8bfc7e07..3663483d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2428,6 +2428,17 @@ class AppLocalizationsDe extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2988,6 +2999,154 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 91db54f6..3864a2a6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsEn extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index cfc8e46a..4184f353 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsEs extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f1354665..76b5227c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2413,6 +2413,17 @@ class AppLocalizationsFr extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2973,6 +2984,154 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index bb4ad22a..36ae71f0 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsHi extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 561eff10..a6820bb7 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2420,6 +2420,17 @@ class AppLocalizationsId extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'trek', + one: 'trek', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2980,6 +2991,154 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Buat'; + + @override + String get collectionFoldersTitle => 'Folder saya'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlist'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Tambahkan ke playlist'; + + @override + String get collectionCreatePlaylist => 'Buat playlist'; + + @override + String get collectionNoPlaylistsYet => 'Belum ada playlist'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Buat playlist untuk mulai mengategorikan lagu'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lagu', + one: '1 lagu', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Ditambahkan ke \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Sudah ada di \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist berhasil dibuat'; + + @override + String get collectionPlaylistNameHint => 'Nama playlist'; + + @override + String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi'; + + @override + String get collectionRenamePlaylist => 'Ubah nama playlist'; + + @override + String get collectionDeletePlaylist => 'Hapus playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Hapus \"$playlistName\" beserta semua lagunya?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist dihapus'; + + @override + String get collectionPlaylistRenamed => 'Nama playlist diperbarui'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist masih kosong'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + di lagu untuk menyimpan yang ingin diunduh nanti'; + + @override + String get collectionLovedEmptyTitle => 'Folder Loved masih kosong'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love di lagu untuk menyimpan favoritmu'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist masih kosong'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Tekan lama tombol + pada lagu untuk menambahkannya ke sini'; + + @override + String get collectionRemoveFromPlaylist => 'Hapus dari playlist'; + + @override + String get collectionRemoveFromFolder => 'Hapus dari folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" dihapus'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" ditambahkan ke Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" dihapus dari Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" ditambahkan ke Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" dihapus dari Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Tambahkan ke Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Hapus dari Loved'; + + @override + String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Ubah gambar sampul'; + + @override + String get collectionPlaylistRemoveCover => 'Hapus gambar sampul'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 2e806040..2e64efd3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2393,6 +2393,17 @@ class AppLocalizationsJa extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2953,6 +2964,154 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d0909a4d..32d7f843 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2406,6 +2406,17 @@ class AppLocalizationsKo extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2966,6 +2977,154 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6de6d29d..4f263f2d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsNl extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e39e5e64..2ab77278 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsPt extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 95b1295b..dbd95cc2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2466,6 +2466,17 @@ class AppLocalizationsRu extends AppLocalizations { return '$count $_temp0'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Последнее сканирование: $time'; @@ -3065,6 +3076,154 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 15e12f36..dda62af2 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2422,6 +2422,17 @@ class AppLocalizationsTr extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2982,6 +2993,154 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f88c9131..9021f5dc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsZh extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7853c4fe..1e0f3572 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1814,6 +1814,13 @@ "count": {"type": "int"} } }, + "libraryTracksUnit": "{count, plural, =1{track} other{tracks}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": {"type": "int"} + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2260,6 +2267,135 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": {"description": "Snackbar when conversion fails"}, + "actionCreate": "Create", + "@actionCreate": {"description": "Generic action button - create"}, + + "collectionFoldersTitle": "My folders", + "@collectionFoldersTitle": {"description": "Library section title for custom folders"}, + "collectionWishlist": "Wishlist", + "@collectionWishlist": {"description": "Custom folder for saved tracks to download later"}, + "collectionLoved": "Loved", + "@collectionLoved": {"description": "Custom folder for favorite tracks"}, + "collectionPlaylists": "Playlists", + "@collectionPlaylists": {"description": "Custom user playlists folder"}, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": {"description": "Single playlist label"}, + "collectionAddToPlaylist": "Add to playlist", + "@collectionAddToPlaylist": {"description": "Action to add a track to user playlist"}, + "collectionCreatePlaylist": "Create playlist", + "@collectionCreatePlaylist": {"description": "Action to create a new playlist"}, + "collectionNoPlaylistsYet": "No playlists yet", + "@collectionNoPlaylistsYet": {"description": "Empty state title when user has no playlists"}, + "collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks", + "@collectionNoPlaylistsSubtitle": {"description": "Empty state subtitle when user has no playlists"}, + "collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": {"type": "int"} + } + }, + "collectionAddedToPlaylist": "Added to \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": {"type": "String"} + } + }, + "collectionAlreadyInPlaylist": "Already in \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": {"type": "String"} + } + }, + "collectionPlaylistCreated": "Playlist created", + "@collectionPlaylistCreated": {"description": "Snackbar after creating playlist"}, + "collectionPlaylistNameHint": "Playlist name", + "@collectionPlaylistNameHint": {"description": "Hint text for playlist name input"}, + "collectionPlaylistNameRequired": "Playlist name is required", + "@collectionPlaylistNameRequired": {"description": "Validation error for empty playlist name"}, + "collectionRenamePlaylist": "Rename playlist", + "@collectionRenamePlaylist": {"description": "Action to rename playlist"}, + "collectionDeletePlaylist": "Delete playlist", + "@collectionDeletePlaylist": {"description": "Action to delete playlist"}, + "collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": {"type": "String"} + } + }, + "collectionPlaylistDeleted": "Playlist deleted", + "@collectionPlaylistDeleted": {"description": "Snackbar after deleting playlist"}, + "collectionPlaylistRenamed": "Playlist renamed", + "@collectionPlaylistRenamed": {"description": "Snackbar after renaming playlist"}, + "collectionWishlistEmptyTitle": "Wishlist is empty", + "@collectionWishlistEmptyTitle": {"description": "Wishlist empty state title"}, + "collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later", + "@collectionWishlistEmptySubtitle": {"description": "Wishlist empty state subtitle"}, + "collectionLovedEmptyTitle": "Loved folder is empty", + "@collectionLovedEmptyTitle": {"description": "Loved empty state title"}, + "collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites", + "@collectionLovedEmptySubtitle": {"description": "Loved empty state subtitle"}, + "collectionPlaylistEmptyTitle": "Playlist is empty", + "@collectionPlaylistEmptyTitle": {"description": "Playlist empty state title"}, + "collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here", + "@collectionPlaylistEmptySubtitle": {"description": "Playlist empty state subtitle"}, + "collectionRemoveFromPlaylist": "Remove from playlist", + "@collectionRemoveFromPlaylist": {"description": "Tooltip for removing track from playlist"}, + "collectionRemoveFromFolder": "Remove from folder", + "@collectionRemoveFromFolder": {"description": "Tooltip for removing track from wishlist/loved folder"}, + "collectionRemoved": "\"{trackName}\" removed", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionAddedToLoved": "\"{trackName}\" added to Loved", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" removed from Loved", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionAddedToWishlist": "\"{trackName}\" added to Wishlist", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": {"type": "String"} + } + }, + + "trackOptionAddToLoved": "Add to Loved", + "@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"}, + "trackOptionRemoveFromLoved": "Remove from Loved", + "@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"}, + "trackOptionAddToWishlist": "Add to Wishlist", + "@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"}, + "trackOptionRemoveFromWishlist": "Remove from Wishlist", + "@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"}, + + "collectionPlaylistChangeCover": "Change cover image", + "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, + "collectionPlaylistRemoveCover": "Remove cover image", + "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}, + "selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 8e2d9214..b74ffdb1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3173,15 +3173,24 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, + "libraryTracksCount": "{count} tracks", + "@libraryTracksCount": { + "description": "Track count in library", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryTracksUnit": "{count, plural, =1{trek} other{trek}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": { + "type": "int" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3924,8 +3933,204 @@ } } }, - "trackConvertFailed": "Conversion failed", - "@trackConvertFailed": { - "description": "Snackbar when conversion fails" - } + "trackConvertFailed": "Conversion failed", + "@trackConvertFailed": { + "description": "Snackbar when conversion fails" + }, + + "actionCreate": "Buat", + "@actionCreate": { + "description": "Generic action button - create" + }, + "collectionFoldersTitle": "Folder saya", + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, + "collectionWishlist": "Wishlist", + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, + "collectionLoved": "Loved", + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, + "collectionPlaylists": "Playlist", + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": { + "description": "Single playlist label" + }, + "collectionAddToPlaylist": "Tambahkan ke playlist", + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, + "collectionCreatePlaylist": "Buat playlist", + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, + "collectionNoPlaylistsYet": "Belum ada playlist", + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, + "collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu", + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, + "collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistCreated": "Playlist berhasil dibuat", + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, + "collectionPlaylistNameHint": "Nama playlist", + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, + "collectionPlaylistNameRequired": "Nama playlist wajib diisi", + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, + "collectionRenamePlaylist": "Ubah nama playlist", + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, + "collectionDeletePlaylist": "Hapus playlist", + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, + "collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistDeleted": "Playlist dihapus", + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, + "collectionPlaylistRenamed": "Nama playlist diperbarui", + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, + "collectionWishlistEmptyTitle": "Wishlist masih kosong", + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, + "collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti", + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, + "collectionLovedEmptyTitle": "Folder Loved masih kosong", + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, + "collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu", + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, + "collectionPlaylistEmptyTitle": "Playlist masih kosong", + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, + "collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini", + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, + "collectionRemoveFromPlaylist": "Hapus dari playlist", + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, + "collectionRemoveFromFolder": "Hapus dari folder", + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, + "collectionRemoved": "\"{trackName}\" dihapus", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + + "trackOptionAddToLoved": "Tambahkan ke Loved", + "@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"}, + "trackOptionRemoveFromLoved": "Hapus dari Loved", + "@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"}, + "trackOptionAddToWishlist": "Tambahkan ke Wishlist", + "@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"}, + "trackOptionRemoveFromWishlist": "Hapus dari Wishlist", + "@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"}, + + "collectionPlaylistChangeCover": "Ubah gambar sampul", + "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, + "collectionPlaylistRemoveCover": "Hapus gambar sampul", + "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"} } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index dfdd3565..ba7b6f09 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -49,6 +49,8 @@ class AppSettings { autoExportFailedDownloads; // Auto export failed downloads to TXT file final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only + final bool + networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning @@ -112,6 +114,7 @@ class AppSettings { this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', + this.networkCompatibilityMode = false, // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', @@ -173,6 +176,7 @@ class AppSettings { bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, + bool? networkCompatibilityMode, // Local Library bool? localLibraryEnabled, String? localLibraryPath, @@ -235,6 +239,8 @@ class AppSettings { autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, + networkCompatibilityMode: + networkCompatibilityMode ?? this.networkCompatibilityMode, // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2fa2870d..04f4028f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -50,6 +50,10 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any', + networkCompatibilityMode: + json['networkCompatibilityMode'] as bool? ?? + json['songLinkCompatibilityMode'] as bool? ?? + false, localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryShowDuplicates: @@ -112,6 +116,7 @@ Map _$AppSettingsToJson( 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, + 'networkCompatibilityMode': instance.networkCompatibilityMode, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart new file mode 100644 index 00000000..e1d7ca1f --- /dev/null +++ b/lib/providers/library_collections_provider.dart @@ -0,0 +1,490 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/track.dart'; + +const _collectionsStorageKey = 'library_collections_v1'; + +String trackCollectionKey(Track track) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + return 'isrc:${isrc.toUpperCase()}'; + } + final source = (track.source?.trim().isNotEmpty ?? false) + ? track.source!.trim() + : 'builtin'; + return '$source:${track.id}'; +} + +class CollectionTrackEntry { + final String key; + final Track track; + final DateTime addedAt; + + const CollectionTrackEntry({ + required this.key, + required this.track, + required this.addedAt, + }); + + Map toJson() => { + 'key': key, + 'track': track.toJson(), + 'addedAt': addedAt.toIso8601String(), + }; + + factory CollectionTrackEntry.fromJson(Map json) { + final addedAtRaw = json['addedAt'] as String?; + return CollectionTrackEntry( + key: json['key'] as String, + track: Track.fromJson(Map.from(json['track'] as Map)), + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } +} + +class UserPlaylistCollection { + final String id; + final String name; + final String? coverImagePath; + final DateTime createdAt; + final DateTime updatedAt; + final List tracks; + + const UserPlaylistCollection({ + required this.id, + required this.name, + this.coverImagePath, + required this.createdAt, + required this.updatedAt, + required this.tracks, + }); + + UserPlaylistCollection copyWith({ + String? id, + String? name, + String? Function()? coverImagePath, + DateTime? createdAt, + DateTime? updatedAt, + List? tracks, + }) { + return UserPlaylistCollection( + id: id ?? this.id, + name: name ?? this.name, + coverImagePath: + coverImagePath != null ? coverImagePath() : this.coverImagePath, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + tracks: tracks ?? this.tracks, + ); + } + + bool containsTrack(Track track) { + final key = trackCollectionKey(track); + return tracks.any((entry) => entry.key == key); + } + + Map toJson() => { + 'id': id, + 'name': name, + if (coverImagePath != null) 'coverImagePath': coverImagePath, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'tracks': tracks.map((e) => e.toJson()).toList(), + }; + + factory UserPlaylistCollection.fromJson(Map json) { + final createdAtRaw = json['createdAt'] as String?; + final updatedAtRaw = json['updatedAt'] as String?; + final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + final tracksRaw = (json['tracks'] as List?) ?? const []; + return UserPlaylistCollection( + id: json['id'] as String, + name: json['name'] as String? ?? '', + coverImagePath: json['coverImagePath'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + ); + } +} + +class LibraryCollectionsState { + final List wishlist; + final List loved; + final List playlists; + final bool isLoaded; + + const LibraryCollectionsState({ + this.wishlist = const [], + this.loved = const [], + this.playlists = const [], + this.isLoaded = false, + }); + + int get wishlistCount => wishlist.length; + int get lovedCount => loved.length; + int get playlistCount => playlists.length; + + bool isInWishlist(Track track) { + final key = trackCollectionKey(track); + return wishlist.any((entry) => entry.key == key); + } + + bool isLoved(Track track) { + final key = trackCollectionKey(track); + return loved.any((entry) => entry.key == key); + } + + UserPlaylistCollection? playlistById(String playlistId) { + for (final playlist in playlists) { + if (playlist.id == playlistId) return playlist; + } + return null; + } + + LibraryCollectionsState copyWith({ + List? wishlist, + List? loved, + List? playlists, + bool? isLoaded, + }) { + return LibraryCollectionsState( + wishlist: wishlist ?? this.wishlist, + loved: loved ?? this.loved, + playlists: playlists ?? this.playlists, + isLoaded: isLoaded ?? this.isLoaded, + ); + } + + Map toJson() => { + 'wishlist': wishlist.map((e) => e.toJson()).toList(), + 'loved': loved.map((e) => e.toJson()).toList(), + 'playlists': playlists.map((e) => e.toJson()).toList(), + }; + + factory LibraryCollectionsState.fromJson(Map json) { + final wishlistRaw = (json['wishlist'] as List?) ?? const []; + final lovedRaw = (json['loved'] as List?) ?? const []; + final playlistsRaw = (json['playlists'] as List?) ?? const []; + + return LibraryCollectionsState( + wishlist: wishlistRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + loved: lovedRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + playlists: playlistsRaw + .whereType() + .map( + (e) => + UserPlaylistCollection.fromJson(Map.from(e)), + ) + .toList(growable: false), + isLoaded: true, + ); + } +} + +class LibraryCollectionsNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + Future? _loadFuture; + + @override + LibraryCollectionsState build() { + _loadFuture = _load(); + return const LibraryCollectionsState(); + } + + Future _load() async { + final prefs = await _prefs; + final raw = prefs.getString(_collectionsStorageKey); + + if (raw == null || raw.isEmpty) { + state = state.copyWith(isLoaded: true); + return; + } + + try { + final parsed = jsonDecode(raw); + if (parsed is Map) { + state = LibraryCollectionsState.fromJson(parsed); + } else { + state = state.copyWith(isLoaded: true); + } + } catch (_) { + state = state.copyWith(isLoaded: true); + } + } + + Future _save() async { + final prefs = await _prefs; + await prefs.setString(_collectionsStorageKey, jsonEncode(state.toJson())); + } + + Future _ensureLoaded() async { + if (state.isLoaded) return; + await (_loadFuture ?? _load()); + } + + Future toggleWishlist(Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + final index = state.wishlist.indexWhere((entry) => entry.key == key); + + if (index >= 0) { + final updated = [...state.wishlist]..removeAt(index); + state = state.copyWith(wishlist: updated); + await _save(); + return false; + } + + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ); + final updated = [entry, ...state.wishlist]; + state = state.copyWith(wishlist: updated); + await _save(); + return true; + } + + Future toggleLoved(Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + final index = state.loved.indexWhere((entry) => entry.key == key); + + if (index >= 0) { + final updated = [...state.loved]..removeAt(index); + state = state.copyWith(loved: updated); + await _save(); + return false; + } + + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ); + final updated = [entry, ...state.loved]; + state = state.copyWith(loved: updated); + await _save(); + return true; + } + + Future removeFromWishlist(String trackKey) async { + await _ensureLoaded(); + final updated = state.wishlist + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (updated.length == state.wishlist.length) return; + state = state.copyWith(wishlist: updated); + await _save(); + } + + Future removeFromLoved(String trackKey) async { + await _ensureLoaded(); + final updated = state.loved + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (updated.length == state.loved.length) return; + state = state.copyWith(loved: updated); + await _save(); + } + + Future createPlaylist(String name) async { + await _ensureLoaded(); + final now = DateTime.now(); + final id = 'pl_${now.microsecondsSinceEpoch}'; + final trimmedName = name.trim(); + + final playlist = UserPlaylistCollection( + id: id, + name: trimmedName, + createdAt: now, + updatedAt: now, + tracks: const [], + ); + + state = state.copyWith(playlists: [playlist, ...state.playlists]); + await _save(); + return id; + } + + Future renamePlaylist(String playlistId, String newName) async { + await _ensureLoaded(); + final trimmed = newName.trim(); + if (trimmed.isEmpty) return; + + final now = DateTime.now(); + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + return playlist.copyWith(name: trimmed, updatedAt: now); + }) + .toList(growable: false); + + state = state.copyWith(playlists: updated); + await _save(); + } + + Future deletePlaylist(String playlistId) async { + await _ensureLoaded(); + final updated = state.playlists + .where((playlist) => playlist.id != playlistId) + .toList(growable: false); + if (updated.length == state.playlists.length) return; + state = state.copyWith(playlists: updated); + await _save(); + } + + Future addTrackToPlaylist(String playlistId, Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + final now = DateTime.now(); + var changed = false; + + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + final alreadyInPlaylist = playlist.tracks.any( + (entry) => entry.key == key, + ); + if (alreadyInPlaylist) return playlist; + changed = true; + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: now, + ); + return playlist.copyWith( + tracks: [entry, ...playlist.tracks], + updatedAt: now, + ); + }) + .toList(growable: false); + + if (!changed) return false; + + state = state.copyWith(playlists: updated); + await _save(); + return true; + } + + Future removeTrackFromPlaylist( + String playlistId, + String trackKey, + ) async { + await _ensureLoaded(); + final now = DateTime.now(); + var changed = false; + + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + final nextTracks = playlist.tracks + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (nextTracks.length == playlist.tracks.length) return playlist; + changed = true; + return playlist.copyWith(tracks: nextTracks, updatedAt: now); + }) + .toList(growable: false); + + if (!changed) return; + + state = state.copyWith(playlists: updated); + await _save(); + } + + /// Returns the directory for storing playlist cover images, creating it + /// if necessary. + Future _playlistCoversDir() async { + final appDir = await getApplicationSupportDirectory(); + final dir = Directory(p.join(appDir.path, 'playlist_covers')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Sets a custom cover image for a playlist by copying the source file + /// into the app's persistent storage. + Future setPlaylistCover( + String playlistId, + String sourceFilePath, + ) async { + await _ensureLoaded(); + final coversDir = await _playlistCoversDir(); + final ext = p.extension(sourceFilePath).toLowerCase(); + final destPath = p.join(coversDir.path, '$playlistId$ext'); + + // Copy image to persistent location + await File(sourceFilePath).copy(destPath); + + final now = DateTime.now(); + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + return playlist.copyWith( + coverImagePath: () => destPath, + updatedAt: now, + ); + }) + .toList(growable: false); + + state = state.copyWith(playlists: updated); + await _save(); + } + + /// Removes the custom cover image for a playlist (falls back to first + /// track's cover). + Future removePlaylistCover(String playlistId) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return; + + // Delete the file if it exists + final path = playlist.coverImagePath; + if (path != null) { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + final now = DateTime.now(); + final updated = state.playlists + .map((pl) { + if (pl.id != playlistId) return pl; + return pl.copyWith(coverImagePath: () => null, updatedAt: now); + }) + .toList(growable: false); + + state = state.copyWith(playlists: updated); + await _save(); + } +} + +final libraryCollectionsProvider = + NotifierProvider( + LibraryCollectionsNotifier.new, + ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1011e549..696d893e 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -45,6 +45,7 @@ class SettingsNotifier extends Notifier { LogBuffer.loggingEnabled = state.enableLogging; _syncLyricsSettingsToBackend(); + _syncNetworkCompatibilitySettingsToBackend(); } void _syncLyricsSettingsToBackend() { @@ -62,6 +63,16 @@ class SettingsNotifier extends Notifier { }); } + void _syncNetworkCompatibilitySettingsToBackend() { + final compatibilityMode = state.networkCompatibilityMode; + PlatformBridge.setNetworkCompatibilityOptions( + allowHttp: compatibilityMode, + insecureTls: compatibilityMode, + ).catchError((e) { + _log.w('Failed to sync network compatibility options to backend: $e'); + }); + } + Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; @@ -466,6 +477,12 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setNetworkCompatibilityMode(bool enabled) { + state = state.copyWith(networkCompatibilityMode: enabled); + _saveSettings(); + _syncNetworkCompatibilitySettingsToBackend(); + } + void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index e9e756fc..555f7a1d 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -4,13 +4,13 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/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/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' @@ -116,7 +116,8 @@ class _AlbumScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -225,12 +226,14 @@ class _AlbumScreenState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final tracks = _tracks ?? []; + final pageBackgroundColor = colorScheme.surface; return Scaffold( + backgroundColor: pageBackgroundColor, body: CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar(context, colorScheme), + _buildAppBar(context, colorScheme, pageBackgroundColor), _buildInfoCard(context, colorScheme), if (_isLoading) const SliverToBoxAdapter( @@ -255,7 +258,11 @@ class _AlbumScreenState extends ConsumerState { ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + Color pageBackgroundColor, + ) { final expandedHeight = _calculateExpandedHeight(context); final tracks = _tracks ?? []; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; @@ -265,7 +272,7 @@ class _AlbumScreenState extends ConsumerState { expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, + backgroundColor: pageBackgroundColor, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -289,14 +296,15 @@ class _AlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ // Full-screen cover background (no blur, full resolution) if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -359,8 +367,7 @@ class _AlbumScreenState extends ConsumerState { if (artistName != null && artistName.isNotEmpty) ...[ const SizedBox(height: 6), GestureDetector( - onTap: () => - _navigateToArtist(context, artistName), + onTap: () => _navigateToArtist(context, artistName), child: Text( artistName, style: TextStyle( @@ -410,16 +417,14 @@ class _AlbumScreenState extends ConsumerState { ], ), ), - if (releaseDate != null && - releaseDate.isNotEmpty) + if (releaseDate != null && releaseDate.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( - color: - Colors.white.withValues(alpha: 0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -445,16 +450,20 @@ class _AlbumScreenState extends ConsumerState { ], ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(tracks.length), - ), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + Center( + child: FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ), @@ -716,13 +725,6 @@ class _AlbumTrackItem extends ConsumerWidget { : false; 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -798,18 +800,7 @@ class _AlbumTrackItem extends ConsumerWidget { ], ], ), - trailing: _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), + trailing: TrackCollectionQuickActions(track: track), onTap: () => _handleTap( context, ref, @@ -869,117 +860,4 @@ class _AlbumTrackItem extends ConsumerWidget { onDownload(); } - - Widget _buildDownloadButton( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, { - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 44.0; - const double iconSize = 20.0; - - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - 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: 3, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), - ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 3, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 10, - 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: onDownload, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), - ); - } - } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index d825ac3e..76c9973d 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -6,7 +6,6 @@ import 'package:intl/intl.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/models/download_item.dart'; 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'; @@ -18,6 +17,7 @@ import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; /// Simple in-memory cache for artist data class _ArtistCache { @@ -1255,13 +1255,6 @@ class _ArtistScreenState extends ConsumerState { : false; 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return InkWell( onTap: () => _handlePopularTrackTap( @@ -1346,16 +1339,8 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - _buildPopularDownloadButton( + TrackCollectionQuickActions( track: track, - colorScheme: colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, ), ], ), @@ -1413,117 +1398,6 @@ class _ArtistScreenState extends ConsumerState { _downloadTrack(track); } - Widget _buildPopularDownloadButton({ - required Track track, - required ColorScheme colorScheme, - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 40.0; - const double iconSize = 20.0; - - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handlePopularTrackTap( - track, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - 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(); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 23892a9e..3187aed0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -11,7 +11,9 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -79,7 +81,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -464,7 +467,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ @@ -478,7 +481,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) else if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -576,9 +580,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), const SizedBox(width: 4), Text( - context.l10n.downloadedAlbumDownloadedCount( - tracks.length, - ), + context.l10n + .downloadedAlbumDownloadedCount( + tracks.length, + ), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, @@ -1099,6 +1104,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { final historyDb = HistoryDatabase.instance; final newQuality = '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -1131,6 +1139,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.spotifyId ?? '', + durationMs: (item.duration ?? 0) * 1000, + ); String? coverPath; try { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f60aa69b..2066e08e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -24,8 +24,8 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -2957,13 +2957,6 @@ class _TrackItemWithStatus extends ConsumerWidget { } 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Column( mainAxisSize: MainAxisSize.min, @@ -3068,17 +3061,8 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), - _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, + TrackCollectionQuickActions( + track: track, ), ], ), @@ -3145,119 +3129,6 @@ class _TrackItemWithStatus extends ConsumerWidget { onDownload(); } - - Widget _buildDownloadButton( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, { - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 44.0; - const double iconSize = 20.0; - - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - 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: 3, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), - ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 3, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 10, - 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: onDownload, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), - ); - } - } } /// Widget for displaying album/playlist items in search results diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart new file mode 100644 index 00000000..e1ad14b9 --- /dev/null +++ b/lib/screens/library_playlists_screen.dart @@ -0,0 +1,558 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; + +class LibraryPlaylistsScreen extends ConsumerWidget { + const LibraryPlaylistsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + + 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); + final leftPadding = 56 - (32 * expandRatio); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + context.l10n.collectionPlaylists, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + if (playlists.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.playlist_play, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + context.l10n.collectionNoPlaylistsSubtitle, + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + // Even indices = playlist tiles, odd indices = dividers + if (index.isOdd) { + return const Divider(height: 1); + } + final playlistIndex = index ~/ 2; + final playlist = playlists[playlistIndex]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 2, + ), + leading: _buildPlaylistThumbnail(context, playlist), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlist.id, + ), + ), + ); + }, + onLongPress: () => + _showPlaylistOptionsSheet(context, ref, playlist), + ); + }, + childCount: playlists.length * 2 - 1, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showCreatePlaylistDialog(context, ref), + icon: const Icon(Icons.add), + label: Text(context.l10n.collectionCreatePlaylist), + ), + ); + } + + void _showPlaylistOptionsSheet( + BuildContext context, + WidgetRef ref, + UserPlaylistCollection playlist, + ) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header: drag handle + thumbnail + playlist info + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: + colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + _buildPlaylistThumbnail(context, playlist), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Rename + _PlaylistOptionTile( + icon: Icons.edit_outlined, + title: context.l10n.collectionRenamePlaylist, + onTap: () { + Navigator.pop(sheetContext); + _showRenamePlaylistDialog( + context, + ref, + playlist.id, + playlist.name, + ); + }, + ), + + // Change cover + _PlaylistOptionTile( + icon: Icons.image_outlined, + title: context.l10n.collectionPlaylistChangeCover, + onTap: () { + Navigator.pop(sheetContext); + _pickCoverImage(context, ref, playlist.id); + }, + ), + + // Delete + _PlaylistOptionTile( + icon: Icons.delete_outline, + iconColor: colorScheme.error, + title: context.l10n.collectionDeletePlaylist, + onTap: () { + Navigator.pop(sheetContext); + _confirmDeletePlaylist( + context, + ref, + playlist.id, + playlist.name, + ); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildPlaylistThumbnail( + BuildContext context, + UserPlaylistCollection playlist, + ) { + final colorScheme = Theme.of(context).colorScheme; + const double size = 48; + final borderRadius = BorderRadius.circular(8); + + // Priority: custom cover > first track cover URL > icon fallback + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + ), + ); + } + + final firstCoverUrl = playlist.tracks + .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) + .map((e) => e.track.coverUrl!) + .firstOrNull; + + if (firstCoverUrl != null) { + return ClipRRect( + borderRadius: borderRadius, + child: CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => _playlistIconFallback(colorScheme, size), + errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), + ), + ); + } + + return _playlistIconFallback(colorScheme, size); + } + + Widget _playlistIconFallback(ColorScheme colorScheme, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.queue_music, + color: colorScheme.onSurfaceVariant, + ), + ); + } + + Future _pickCoverImage( + BuildContext context, + WidgetRef ref, + String playlistId, + ) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .setPlaylistCover(playlistId, path); + } + + Future _showCreatePlaylistDialog( + BuildContext context, + WidgetRef ref, + ) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + if (playlistName == null || + playlistName.trim().isEmpty || + !context.mounted) { + return; + } + + await ref + .read(libraryCollectionsProvider.notifier) + .createPlaylist(playlistName.trim()); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), + ); + } + + Future _showRenamePlaylistDialog( + BuildContext context, + WidgetRef ref, + String playlistId, + String currentName, + ) async { + final controller = TextEditingController(text: currentName); + final formKey = GlobalKey(); + + final nextName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionRenamePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.dialogSave), + ), + ], + ); + }, + ); + + if (nextName == null || nextName.trim().isEmpty || !context.mounted) { + return; + } + + await ref + .read(libraryCollectionsProvider.notifier) + .renamePlaylist(playlistId, nextName.trim()); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)), + ); + } + + Future _confirmDeletePlaylist( + BuildContext context, + WidgetRef ref, + String playlistId, + String playlistName, + ) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionDeletePlaylist), + content: Text( + dialogContext.l10n.collectionDeletePlaylistMessage(playlistName), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(dialogContext.l10n.dialogDelete), + ), + ], + ); + }, + ); + + if (confirmed != true || !context.mounted) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .deletePlaylist(playlistId); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistDeleted)), + ); + } +} + +/// Styled like _OptionTile in track_collection_quick_actions.dart +class _PlaylistOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _PlaylistOptionTile({ + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart new file mode 100644 index 00000000..e606460f --- /dev/null +++ b/lib/screens/library_tracks_folder_screen.dart @@ -0,0 +1,884 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +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/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; + +class LibraryTracksFolderScreen extends ConsumerStatefulWidget { + final LibraryTracksFolderMode mode; + final String? playlistId; + + const LibraryTracksFolderScreen({ + super.key, + required this.mode, + this.playlistId, + }); + + @override + ConsumerState createState() => + _LibraryTracksFolderScreenState(); +} + +class _LibraryTracksFolderScreenState + extends ConsumerState { + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.45).clamp(300.0, 420.0); + } + + IconData _modeIcon() { + return switch (widget.mode) { + LibraryTracksFolderMode.wishlist => Icons.bookmark, + LibraryTracksFolderMode.loved => Icons.favorite, + LibraryTracksFolderMode.playlist => Icons.queue_music, + }; + } + + /// Find the first available cover URL from entries. + String? _firstCoverUrl(List entries) { + for (final entry in entries) { + if (entry.track.coverUrl != null && entry.track.coverUrl!.isNotEmpty) { + return entry.track.coverUrl; + } + } + return null; + } + + /// Returns true if [url] is a local file path rather than a network URL. + bool _isCoverLocalPath(String url) { + return !url.startsWith('http://') && !url.startsWith('https://'); + } + + /// Upgrade cover URL to higher resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final state = ref.watch(libraryCollectionsProvider); + final playlist = + widget.mode == LibraryTracksFolderMode.playlist && + widget.playlistId != null + ? state.playlistById(widget.playlistId!) + : null; + + final entries = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => state.wishlist, + LibraryTracksFolderMode.loved => state.loved, + LibraryTracksFolderMode.playlist => + playlist?.tracks ?? const [], + }; + + final title = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => + playlist?.name ?? context.l10n.collectionPlaylist, + }; + + final emptyTitle = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => + context.l10n.collectionWishlistEmptyTitle, + LibraryTracksFolderMode.loved => context.l10n.collectionLovedEmptyTitle, + LibraryTracksFolderMode.playlist => + context.l10n.collectionPlaylistEmptyTitle, + }; + + final emptySubtitle = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => + context.l10n.collectionWishlistEmptySubtitle, + LibraryTracksFolderMode.loved => + context.l10n.collectionLovedEmptySubtitle, + LibraryTracksFolderMode.playlist => + context.l10n.collectionPlaylistEmptySubtitle, + }; + + return Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar(context, colorScheme, title, entries, playlist), + if (entries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyFolderState( + title: emptyTitle, + subtitle: emptySubtitle, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final entry = entries[index]; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + ), + if (index < entries.length - 1) + const Divider(height: 1), + ], + ); + }, + childCount: entries.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Future _pickCoverImage() async { + final playlistId = widget.playlistId; + if (playlistId == null) return; + + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .setPlaylistCover(playlistId, path); + } + + Future _removeCoverImage() async { + final playlistId = widget.playlistId; + if (playlistId == null) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .removePlaylistCover(playlistId); + } + + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + String title, + List entries, + UserPlaylistCollection? playlist, + ) { + final expandedHeight = _calculateExpandedHeight(context); + final customCoverPath = playlist?.coverImagePath; + final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; + final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; + // Loved always shows the heart icon (like Spotify's Liked Songs) + final coverUrl = isLovedMode ? null : _firstCoverUrl(entries); + final hasCustomCover = + customCoverPath != null && customCoverPath.isNotEmpty; + final hasCoverUrl = coverUrl != null; + + return SliverAppBar( + expandedHeight: expandedHeight, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + title, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + actions: [ + if (isPlaylistMode) + IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.camera_alt_outlined, + color: Colors.white, + size: 20, + ), + ), + onPressed: () => _showCoverOptionsSheet(context, hasCustomCover), + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = + (constraints.maxHeight - kToolbarHeight) / + (expandedHeight - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + // Cover background: custom > first track URL > icon + if (hasCustomCover) + Image.file( + File(customCoverPath), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + else if (hasCoverUrl) + _isCoverLocalPath(coverUrl) + ? Image.file( + File(coverUrl), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ) + : CachedNetworkImage( + imageUrl: + _highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ) + else + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ), + // Bottom gradient for readability + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + ), + ), + ), + ), + // Title and track count overlay + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (entries.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _modeIcon(), + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(entries.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground], + ); + }, + ), + leading: IconButton( + 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), + ), + ); + } + + void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_outlined, + color: colorScheme.onPrimaryContainer, + ), + ), + title: Text(context.l10n.collectionPlaylistChangeCover), + onTap: () { + Navigator.pop(sheetContext); + _pickCoverImage(); + }, + ), + if (hasCustomCover) + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: colorScheme.onErrorContainer, + ), + ), + title: Text(context.l10n.collectionPlaylistRemoveCover), + onTap: () { + Navigator.pop(sheetContext); + _removeCoverImage(); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +class _CollectionTrackTile extends ConsumerWidget { + final CollectionTrackEntry entry; + final LibraryTracksFolderMode mode; + final String? playlistId; + + const _CollectionTrackTile({ + required this.entry, + required this.mode, + required this.playlistId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final track = entry.track; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: track.coverUrl != null && track.coverUrl!.isNotEmpty + ? _buildTrackCover(context, track.coverUrl!, 52) + : Container( + width: 52, + height: 52, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: Icon( + Icons.more_vert, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + ), + onTap: mode == LibraryTracksFolderMode.wishlist + ? () => _downloadTrack(context, ref) + : mode == LibraryTracksFolderMode.playlist + ? () => _openInMusicPlayer(context, ref) + : null, + onLongPress: () => _showTrackOptionsSheet(context, ref), + ); + } + + /// Builds a cover image widget that handles both network URLs and local file paths. + Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { + final isLocal = + !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); + final colorScheme = Theme.of(context).colorScheme; + + if (isLocal) { + return Image.file( + File(coverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ); + } + + return CachedNetworkImage( + imageUrl: coverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ); + } + + void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { + final track = entry.track; + final colorScheme = Theme.of(context).colorScheme; + final isDownloaded = ref.read( + downloadHistoryProvider.select((state) => state.isDownloaded(track.id)), + ); + // Wishlist: only show "Add to Playlist" if track is already downloaded + final showAddToPlaylist = + mode != LibraryTracksFolderMode.wishlist || isDownloaded; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header: drag handle + cover + track info + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: + colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: track.coverUrl != null && + track.coverUrl!.isNotEmpty + ? _buildTrackCover(context, track.coverUrl!, 56) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + track.artistName, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Add to playlist (hidden in wishlist unless already downloaded) + if (showAddToPlaylist) + _CollectionOptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(sheetContext); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + // Remove from folder / playlist + _CollectionOptionTile( + icon: Icons.remove_circle_outline, + iconColor: colorScheme.error, + title: mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + onTap: () { + Navigator.pop(sheetContext); + _removeFromCurrentFolder(context, ref); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Future _removeFromCurrentFolder( + BuildContext context, + WidgetRef ref, + ) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final key = entry.key; + + switch (mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeFromWishlist(key); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeFromLoved(key); + break; + case LibraryTracksFolderMode.playlist: + if (playlistId != null) { + await notifier.removeTrackFromPlaylist(playlistId!, key); + } + break; + } + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionRemoved(entry.track.name))), + ); + } + + void _downloadTrack(BuildContext context, WidgetRef ref) { + final track = entry.track; + final settings = ref.read(settingsProvider); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); + }, + ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); + } + } + + Future _openInMusicPlayer(BuildContext context, WidgetRef ref) async { + final track = entry.track; + final historyItem = ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyId(track.id); + + if (historyItem == null) return; + + final exists = await fileExists(historyItem.filePath); + if (!exists) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile('File not found'), + ), + ), + ); + return; + } + + try { + await openFile(historyItem.filePath); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), + ); + } + } +} + +/// Styled like _OptionTile in track_collection_quick_actions.dart +class _CollectionOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _CollectionOptionTile({ + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} + +class _EmptyFolderState extends StatelessWidget { + final String title; + final String subtitle; + + const _EmptyFolderState({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_open, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + subtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +enum LibraryTracksFolderMode { wishlist, loved, playlist } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e144882f..e7642a45 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -66,7 +67,8 @@ class _LocalAlbumScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -311,7 +313,7 @@ class _LocalAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ @@ -1188,6 +1190,9 @@ class _LocalAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final localDb = LibraryDatabase.instance; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -1220,6 +1225,14 @@ class _LocalAlbumScreenState extends ConsumerState { }); } } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + durationMs: (item.duration ?? 0) * 1000, + ); String? coverPath; try { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 97de41dc..f127d9d7 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -5,12 +5,12 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.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/download_queue_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -119,7 +119,8 @@ class _PlaylistScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -196,14 +197,15 @@ class _PlaylistScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ // Full-screen cover background if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -295,16 +297,20 @@ class _PlaylistScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(_tracks.length), - ), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + Center( + child: FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(_tracks.length), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ), @@ -509,13 +515,6 @@ class _PlaylistTrackItem extends ConsumerWidget { : false; 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -603,17 +602,8 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ], ), - trailing: _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, + trailing: TrackCollectionQuickActions( + track: track, ), onTap: () => _handleTap( context, @@ -674,117 +664,4 @@ class _PlaylistTrackItem extends ConsumerWidget { onDownload(); } - - Widget _buildDownloadButton( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, { - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 44.0; - const double iconSize = 20.0; - - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - 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: 3, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), - ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 3, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 10, - 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: onDownload, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), - ); - } - } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 6dc317f7..f26a8b2f 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -13,8 +13,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; @@ -22,6 +25,7 @@ import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; /// Represents the source of a library item @@ -112,6 +116,76 @@ class UnifiedLibraryItem { '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + /// Returns the collection key used to match this item against playlist + /// entries. Uses the same logic as [trackCollectionKey] from the collections + /// provider: prefer ISRC, fall back to source:id. + String get collectionKey { + if (historyItem != null) { + final isrc = historyItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + final source = historyItem!.service.trim().isNotEmpty + ? historyItem!.service.trim() + : 'builtin'; + return '$source:${historyItem!.id}'; + } + if (localItem != null) { + final isrc = localItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + return 'local:${localItem!.id}'; + } + return 'builtin:$id'; + } + + /// Convert to a [Track] for adding to collections/playlists. + Track toTrack() { + if (historyItem != null) { + final h = historyItem!; + return Track( + id: h.id, + name: h.trackName, + artistName: h.artistName, + albumName: h.albumName, + albumArtist: h.albumArtist, + coverUrl: h.coverUrl, + isrc: h.isrc, + duration: h.duration ?? 0, + trackNumber: h.trackNumber, + discNumber: h.discNumber, + releaseDate: h.releaseDate, + source: h.service, + ); + } + if (localItem != null) { + final l = localItem!; + // Store coverPath (even local file paths) in coverUrl so playlist + // entries retain the cover. All renderers must check whether the + // value is a URL or a local path and use the appropriate widget. + return Track( + id: l.id, + name: l.trackName, + artistName: l.artistName, + albumName: l.albumName, + albumArtist: l.albumArtist, + coverUrl: l.coverPath, + isrc: l.isrc, + duration: l.duration ?? 0, + trackNumber: l.trackNumber, + discNumber: l.discNumber, + releaseDate: l.releaseDate, + source: 'local', + ); + } + // Fallback — should not happen + return Track( + id: id, + name: trackName, + artistName: artistName, + albumName: albumName, + coverUrl: coverUrl, + duration: 0, + ); + } } class _GroupedAlbum { @@ -296,6 +370,9 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + bool _isPlaylistSelectionMode = false; + final Set _selectedPlaylistIds = {}; + PageController? _filterPageController; final List _filterModes = ['all', 'albums', 'singles']; bool _isPageControllerInitialized = false; @@ -696,6 +773,211 @@ class _QueueTabState extends ConsumerState { }); } + // --- Playlist selection mode --- + + void _enterPlaylistSelectionMode(String playlistId) { + HapticFeedback.mediumImpact(); + setState(() { + _isPlaylistSelectionMode = true; + _selectedPlaylistIds.add(playlistId); + }); + } + + void _exitPlaylistSelectionMode() { + setState(() { + _isPlaylistSelectionMode = false; + _selectedPlaylistIds.clear(); + }); + } + + void _togglePlaylistSelection(String playlistId) { + setState(() { + if (_selectedPlaylistIds.contains(playlistId)) { + _selectedPlaylistIds.remove(playlistId); + if (_selectedPlaylistIds.isEmpty) { + _isPlaylistSelectionMode = false; + } + } else { + _selectedPlaylistIds.add(playlistId); + } + }); + } + + void _selectAllPlaylists(List playlists) { + setState(() { + _selectedPlaylistIds.addAll(playlists.map((e) => e.id)); + }); + } + + Future _deleteSelectedPlaylists(BuildContext context) async { + final count = _selectedPlaylistIds.length; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.collectionDeletePlaylist), + content: Text( + 'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(ctx.l10n.dialogDelete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + final notifier = ref.read(libraryCollectionsProvider.notifier); + for (final id in _selectedPlaylistIds.toList()) { + await notifier.deletePlaylist(id); + } + + if (!context.mounted) return; + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$count ${count == 1 ? 'playlist' : 'playlists'} deleted', + ), + ), + ); + } + + /// Bottom action bar for playlist selection mode. + Widget _buildPlaylistSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List playlists, + double bottomPadding, + ) { + final selectedCount = _selectedPlaylistIds.length; + final allSelected = + selectedCount == playlists.length && playlists.isNotEmpty; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitPlaylistSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$selectedCount selected', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? 'All playlists selected' + : 'Tap playlists to select', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitPlaylistSelectionMode(); + } else { + _selectAllPlaylists(playlists); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text(allSelected ? 'Deselect' : 'Select All'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _deleteSelectedPlaylists(context) + : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' + : 'Select playlists to delete', + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + String _getQualityBadgeText(String quality) { final q = quality.trim().toLowerCase(); if (q.contains('bit')) { @@ -1691,6 +1973,289 @@ class _QueueTabState extends ConsumerState { ).then((_) => _searchFocusNode.unfocus()); } + void _openWishlistFolder() { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.wishlist, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + void _openLovedFolder() { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.loved, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + void _openPlaylistById(String playlistId) { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlistId, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + Future _showCreatePlaylistDialog(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + if (playlistName == null || playlistName.isEmpty) return; + await ref + .read(libraryCollectionsProvider.notifier) + .createPlaylist(playlistName); + } + + /// Build a playlist cover thumbnail (custom cover > first track cover > icon fallback). + /// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view + /// where the widget should expand to fill its parent. + Widget _buildPlaylistCover( + UserPlaylistCollection playlist, + ColorScheme colorScheme, [ + double? size, + ]) { + final borderRadius = BorderRadius.circular(8); + + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + _playlistIconFallback(colorScheme, size), + ), + ); + } + + final firstCoverUrl = playlist.tracks + .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) + .map((e) => e.track.coverUrl!) + .firstOrNull; + + if (firstCoverUrl != null) { + // Guard against local file paths that may have been stored as coverUrl + final isLocalPath = !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + if (isLocalPath) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + _playlistIconFallback(colorScheme, size), + ), + ); + } + return ClipRRect( + borderRadius: borderRadius, + child: CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => + _playlistIconFallback(colorScheme, size), + errorWidget: (_, _, _) => + _playlistIconFallback(colorScheme, size), + ), + ); + } + + return _playlistIconFallback(colorScheme, size); + } + + /// Icon fallback for playlists with no cover. + /// When [size] is null the container expands to fill its parent (grid view) + /// and uses a fixed icon size. + Widget _playlistIconFallback(ColorScheme colorScheme, [double? size]) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFF5085A5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.queue_music, + color: Colors.white, + size: size != null ? size * 0.5 : 40, + ), + ); + } + + /// Handle a track being dropped onto a playlist. + /// When selection mode is active and the dragged item is among the selected, + /// all selected tracks are added to the playlist. + Future _onTrackDroppedOnPlaylist( + BuildContext context, + UnifiedLibraryItem item, + String playlistId, + String playlistName, { + List allItems = const [], + }) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + + // If in selection mode and the dragged item is selected, add ALL selected + if (_isSelectionMode && + _selectedIds.isNotEmpty && + _selectedIds.contains(item.id)) { + final selectedItems = allItems + .where((e) => _selectedIds.contains(e.id)) + .toList(); + // Fallback: if allItems is empty or no match, at least add the dragged item + if (selectedItems.isEmpty) { + selectedItems.add(item); + } + + int addedCount = 0; + int alreadyCount = 0; + for (final selected in selectedItems) { + final track = selected.toTrack(); + final added = await notifier.addTrackToPlaylist(playlistId, track); + if (added) { + addedCount++; + } else { + alreadyCount++; + } + } + + if (!context.mounted) return; + final message = addedCount > 0 + ? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName' + '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' + : context.l10n.collectionAlreadyInPlaylist(playlistName); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + _exitSelectionMode(); + return; + } + + // Single track drop + final track = item.toTrack(); + final added = await notifier.addTrackToPlaylist(playlistId, track); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToPlaylist(playlistName) + : context.l10n.collectionAlreadyInPlaylist(playlistName), + ), + ), + ); + } + + /// Build a compact floating feedback widget shown while dragging a track. + /// Shows the count when multiple tracks are selected and being dragged. + Widget _buildDragFeedback( + BuildContext context, + UnifiedLibraryItem item, + ColorScheme colorScheme, + ) { + final isDraggingMultiple = _isSelectionMode && + _selectedIds.contains(item.id) && + _selectedIds.length > 1; + final count = isDraggingMultiple ? _selectedIds.length : 1; + + return Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.playlist_add, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + isDraggingMultiple + ? '$count tracks' + : item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { _initializePageController(); @@ -1708,6 +2273,7 @@ class _QueueTabState extends ConsumerState { final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; + final collectionState = ref.watch(libraryCollectionsProvider); _ensureHistoryCaches(allHistoryItems, localLibraryItems); final historyViewMode = ref.watch( @@ -1747,6 +2313,7 @@ class _QueueTabState extends ConsumerState { albumCounts: historyStats.albumCounts, localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, + collectionState: collectionState, ), ); } @@ -2020,6 +2587,7 @@ class _QueueTabState extends ConsumerState { hasQueueItems: hasQueueItems, filterData: filterData, localLibraryItems: localLibraryItems, + collectionState: collectionState, ); }, ), @@ -2043,11 +2611,29 @@ class _QueueTabState extends ConsumerState { albumCounts: historyStats.albumCounts, localLibraryItems: localLibraryItems, localAlbumCounts: historyStats.localAlbumCounts, + collectionState: collectionState, ), bottomPadding, ) : const SizedBox.shrink(), ), + + // Playlist selection bottom bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isPlaylistSelectionMode ? 0 : -(200 + bottomPadding), + child: _isPlaylistSelectionMode + ? _buildPlaylistSelectionBottomBar( + context, + colorScheme, + collectionState.playlists, + bottomPadding, + ) + : const SizedBox.shrink(), + ), ], ), ); @@ -2060,6 +2646,7 @@ class _QueueTabState extends ConsumerState { required Map albumCounts, required List localLibraryItems, required Map localAlbumCounts, + required LibraryCollectionsState collectionState, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -2075,7 +2662,19 @@ class _QueueTabState extends ConsumerState { ); // Apply advanced filters to match what's displayed - return _applyAdvancedFilters(unifiedItems); + final filtered = _applyAdvancedFilters(unifiedItems); + + // Exclude tracks already in a playlist + final playlistTrackKeys = {}; + for (final playlist in collectionState.playlists) { + for (final entry in playlist.tracks) { + playlistTrackKeys.add(entry.key); + } + } + if (playlistTrackKeys.isEmpty) return filtered; + return filtered + .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .toList(growable: false); } List _getUnifiedItems({ @@ -2139,6 +2738,7 @@ class _QueueTabState extends ConsumerState { required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, + required LibraryCollectionsState collectionState, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -2156,7 +2756,24 @@ class _QueueTabState extends ConsumerState { localLibraryItems: localLibraryItems, localAlbumCounts: localAlbumCounts, ); - final filteredUnifiedItems = _applyAdvancedFilters(unifiedItems); + final filtered = _applyAdvancedFilters(unifiedItems); + + // Remove tracks that are already in any playlist so they don't appear + // in the main tracks list. When a track is removed from a playlist (or + // the playlist is deleted) it will automatically reappear here because it + // will no longer be in the set. + final playlistTrackKeys = {}; + for (final playlist in collectionState.playlists) { + for (final entry in playlist.tracks) { + playlistTrackKeys.add(entry.key); + } + } + + final filteredUnifiedItems = playlistTrackKeys.isEmpty + ? filtered + : filtered + .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .toList(growable: false); return _FilterContentData( historyItems: historyItems, @@ -2229,6 +2846,358 @@ class _QueueTabState extends ConsumerState { ); } + /// Build a Spotify-style collection list item (Wishlist, Loved, Playlists) + Widget _buildCollectionListItem({ + required BuildContext context, + required ColorScheme colorScheme, + IconData? icon, + Color? iconColor, + Color? iconBgColor, + Widget? coverWidget, + required String title, + required String subtitle, + required VoidCallback onTap, + VoidCallback? onLongPress, + }) { + final cover = coverWidget ?? + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: iconBgColor ?? colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 28), + ); + + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + SizedBox(width: 56, height: 56, child: cover), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Build a collection grid item for grid view mode + Widget _buildCollectionGridItem({ + required BuildContext context, + required ColorScheme colorScheme, + IconData? icon, + Color? iconColor, + Color? iconBgColor, + Widget? coverWidget, + required String title, + required int count, + required VoidCallback onTap, + VoidCallback? onLongPress, + }) { + final cover = coverWidget ?? + Container( + decoration: BoxDecoration( + color: iconBgColor ?? colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 40), + ); + + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: cover, + ), + ), + const SizedBox(height: 6), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + '$count ${count == 1 ? 'item' : 'items'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + /// Build a collection item at [index] for the unified "All" tab grid view. + /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + Widget _buildAllTabGridCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required int index, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + if (index == 0) { + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + count: collectionState.wishlistCount, + onTap: _openWishlistFolder, + ); + } else if (index == 1) { + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + count: collectionState.lovedCount, + onTap: _openLovedFolder, + ); + } else { + final playlist = collectionState.playlists[index - 2]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : isSelected + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.08), + ) + : null, + child: Stack( + children: [ + _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover(playlist, colorScheme), + title: playlist.name, + count: playlist.tracks.length, + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + if (_isPlaylistSelectionMode) + Positioned( + top: 4, + right: 4, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, size: 16, + color: colorScheme.onPrimary) + : const SizedBox(width: 16, height: 16), + ), + ), + ], + ), + ); + }, + ); + } + } + + /// Build a collection item at [index] for the unified "All" tab list view. + /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + Widget _buildAllTabListCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required int index, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + if (index == 0) { + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', + onTap: _openWishlistFolder, + ); + } else if (index == 1) { + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', + onTap: _openLovedFolder, + ); + } else { + final playlist = collectionState.playlists[index - 2]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : isSelected + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.08), + ) + : null, + child: Row( + children: [ + if (_isPlaylistSelectionMode) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, size: 18, + color: colorScheme.onPrimary) + : const SizedBox(width: 18, height: 18), + ), + ), + Expanded( + child: _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover(playlist, colorScheme, 56), + title: playlist.name, + subtitle: + '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + ), + ], + ), + ); + }, + ); + } + } + Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -2237,6 +3206,7 @@ class _QueueTabState extends ConsumerState { required bool hasQueueItems, required _FilterContentData filterData, required List localLibraryItems, + required LibraryCollectionsState collectionState, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; @@ -2284,10 +3254,9 @@ class _QueueTabState extends ConsumerState { ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => - _enterSelectionMode(filteredUnifiedItems.first.id), - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), + onPressed: () => _showCreatePlaylistDialog(context), + icon: const Icon(Icons.add, size: 20), + label: Text(context.l10n.collectionCreatePlaylist), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), @@ -2297,6 +3266,9 @@ class _QueueTabState extends ConsumerState { ), ), + // Collection folders as list items (Spotify-style) in "All" tab + // are now rendered inline with tracks below (unified sliver) + if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && filterMode == 'albums') @@ -2444,45 +3416,119 @@ class _QueueTabState extends ConsumerState { ), ), - // Unified list for 'all' filter (merged downloaded + local) - if (filteredUnifiedItems.isNotEmpty && filterMode == 'all') - historyViewMode == 'grid' - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, + // Unified list/grid for 'all' filter: collection items + tracks combined + if (filterMode == 'all') ...[ + if (historyViewMode == 'grid') + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabGridCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = filteredUnifiedItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), child: _buildUnifiedGridItem( context, item, colorScheme, ), - ); - }, childCount: filteredUnifiedItems.length), - ), - ) - : SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = filteredUnifiedItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabListCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedLibraryItem( + context, + item, + colorScheme, + ), + ), child: _buildUnifiedLibraryItem( context, item, colorScheme, ), - ); - }, childCount: filteredUnifiedItems.length), - ), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ], // Singles filter - show unified items (downloaded + local singles) if (filterMode == 'singles') @@ -2519,10 +3565,9 @@ class _QueueTabState extends ConsumerState { ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => - _enterSelectionMode(filteredUnifiedItems.first.id), - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), + onPressed: () => _showCreatePlaylistDialog(context), + icon: const Icon(Icons.add, size: 20), + label: Text(context.l10n.collectionCreatePlaylist), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), @@ -3442,6 +4487,9 @@ class _QueueTabState extends ConsumerState { final historyDb = HistoryDatabase.instance; final newQuality = '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -3475,6 +4523,17 @@ class _QueueTabState extends ConsumerState { }); } } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.historyItem?.spotifyId ?? '', + durationMs: + ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * + 1000, + ); // Extract cover art String? coverPath; @@ -4455,14 +5514,18 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 8), - Text( - dateStr, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), ), - ), + ), ), if (item.quality != null && item.quality!.isNotEmpty) ...[ diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d85d447f..142035af 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -175,10 +175,7 @@ class _SearchScreenState extends ConsumerState { ), ], ), - trailing: IconButton( - icon: Icon(Icons.download, color: colorScheme.primary), - onPressed: () => _downloadTrack(track), - ), + trailing: null, onTap: () => _downloadTrack(track), ); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index ac07f913..6577367b 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -312,8 +312,10 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsItem( icon: Icons.lyrics_outlined, title: context.l10n.lyricsMode, - subtitle: - _getLyricsModeLabel(context, settings.lyricsMode), + subtitle: _getLyricsModeLabel( + context, + settings.lyricsMode, + ), onTap: () => _showLyricsModePicker( context, ref, @@ -534,6 +536,19 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + SettingsSwitchItem( + icon: Icons.security_outlined, + title: 'Network compatibility mode', + subtitle: settings.networkCompatibilityMode + ? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)' + : 'Off: strict HTTPS certificate validation (recommended)', + value: settings.networkCompatibilityMode, + onChanged: (value) { + ref + .read(settingsProvider.notifier) + .setNetworkCompatibilityMode(value); + }, + ), SettingsSwitchItem( icon: Icons.file_download_outlined, title: context.l10n.settingsAutoExportFailed, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 696baee9..d8d32dda 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -656,14 +656,8 @@ class _LibraryHeroCard extends StatelessWidget { const SizedBox(height: 4), Text( isScanning - ? context.l10n - .libraryTracksCount(scannedFiles) - .replaceAll(scannedFiles.toString(), '') - .trim() - : context.l10n - .libraryTracksCount(displayCount) - .replaceAll(displayCount.toString(), '') - .trim(), + ? context.l10n.libraryTracksUnit(scannedFiles) + : context.l10n.libraryTracksUnit(displayCount), style: TextStyle( fontSize: 16, color: colorScheme.onSurfaceVariant, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 5be27f4e..29bf2b21 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -548,7 +548,7 @@ class _TrackMetadataScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: _buildHeaderBackground( context, colorScheme, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 9b25456f..6e121f4c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -133,6 +133,16 @@ class PlatformBridge { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } + static Future setNetworkCompatibilityOptions({ + required bool allowHttp, + required bool insecureTls, + }) async { + await _channel.invokeMethod('setNetworkCompatibilityOptions', { + 'allow_http': allowHttp, + 'insecure_tls': insecureTls, + }); + } + static Future> checkDuplicate( String outputDir, String isrc, @@ -244,7 +254,10 @@ class PlatformBridge { return result as bool? ?? false; } - static Future shareMultipleContentUris(List uris, {String title = ''}) async { + static Future shareMultipleContentUris( + List uris, { + String title = '', + }) async { final result = await _channel.invokeMethod('shareMultipleContentUris', { 'uris': uris, 'title': title, diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart new file mode 100644 index 00000000..2e17c397 --- /dev/null +++ b/lib/utils/lyrics_metadata_helper.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +bool hasEmbeddedLyricsMetadata(Map metadata) { + final lyrics = (metadata['LYRICS'] ?? '').trim(); + if (lyrics.isNotEmpty) return true; + + final unsyncedLyrics = (metadata['UNSYNCEDLYRICS'] ?? '').trim(); + if (unsyncedLyrics.isNotEmpty) return true; + + return false; +} + +String _sidecarLrcPath(String path) { + final slash = path.lastIndexOf(Platform.pathSeparator); + final dot = path.lastIndexOf('.'); + if (dot > slash) { + return '${path.substring(0, dot)}.lrc'; + } + return '$path.lrc'; +} + +Future ensureLyricsMetadataForConversion({ + required Map metadata, + required String sourcePath, + required bool shouldEmbedLyrics, + required String trackName, + required String artistName, + String spotifyId = '', + int durationMs = 0, +}) async { + if (!shouldEmbedLyrics || hasEmbeddedLyricsMetadata(metadata)) { + return; + } + + String? lyrics; + + // Prefer sidecar .lrc when available to avoid network calls. + if (!isContentUri(sourcePath)) { + try { + final lrcPath = _sidecarLrcPath(sourcePath); + final lrcFile = File(lrcPath); + if (await lrcFile.exists()) { + final content = (await lrcFile.readAsString()).trim(); + if (content.isNotEmpty) { + lyrics = content; + } + } + } catch (_) {} + } + + if (lyrics == null || lyrics.isEmpty) { + try { + final fetched = await PlatformBridge.getLyricsLRC( + spotifyId, + trackName, + artistName, + durationMs: durationMs, + ); + final normalized = fetched.trim(); + if (normalized.isNotEmpty && + normalized.toLowerCase() != '[instrumental:true]') { + lyrics = normalized; + } + } catch (_) {} + } + + if (lyrics == null || lyrics.isEmpty) { + return; + } + + metadata['LYRICS'] = lyrics; + metadata['UNSYNCEDLYRICS'] = lyrics; +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart new file mode 100644 index 00000000..7eb7ee42 --- /dev/null +++ b/lib/widgets/playlist_picker_sheet.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; + +Future showAddTrackToPlaylistSheet( + BuildContext context, + WidgetRef ref, + Track track, +) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final playlists = ref.watch(libraryCollectionsProvider).playlists; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(sheetContext.l10n.collectionAddToPlaylist), + subtitle: Text('${track.name} • ${track.artistName}'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(sheetContext.l10n.collectionCreatePlaylist), + onTap: () async { + Navigator.of(sheetContext).pop(); + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + final added = await notifier.addTrackToPlaylist( + playlistId, + track, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToPlaylist(name.trim()) + : context.l10n.collectionAlreadyInPlaylist( + name.trim(), + ), + ), + ), + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + sheetContext.l10n.collectionNoPlaylistsYet, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + final alreadyInPlaylist = playlist.containsTrack(track); + return ListTile( + leading: Icon( + alreadyInPlaylist + ? Icons.playlist_add_check + : Icons.queue_music, + ), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + enabled: !alreadyInPlaylist, + onTap: !alreadyInPlaylist + ? () async { + final added = await notifier.addTrackToPlaylist( + playlist.id, + track, + ); + if (!context.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n + .collectionAddedToPlaylist( + playlist.name, + ) + : context.l10n + .collectionAlreadyInPlaylist( + playlist.name, + ), + ), + ), + ); + } + : null, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + + if (!context.mounted) return; + + final afterState = ref.read(libraryCollectionsProvider); + if (afterState.playlists.length != state.playlists.length) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), + ); + } +} + +Future _promptPlaylistName(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final result = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + return result; +} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart new file mode 100644 index 00000000..1f9e345d --- /dev/null +++ b/lib/widgets/track_collection_quick_actions.dart @@ -0,0 +1,254 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; + +class TrackCollectionQuickActions extends ConsumerWidget { + final Track track; + + const TrackCollectionQuickActions({ + super.key, + required this.track, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return IconButton( + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + padding: const EdgeInsets.only(left: 12), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ); + } + + void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _TrackOptionsSheet(track: track), + ); + } +} + +class _TrackOptionsSheet extends ConsumerWidget { + final Track track; + + const _TrackOptionsSheet({required this.track}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final isLoved = ref.watch( + libraryCollectionsProvider.select((state) => state.isLoved(track)), + ); + final isInWishlist = ref.watch( + libraryCollectionsProvider.select((state) => state.isInWishlist(track)), + ); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with drag handle + track info (matches _TrackInfoHeader) + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: track.coverUrl != null && track.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + track.artistName, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Action items (matches _QualityOption style) + _OptionTile( + icon: isLoved ? Icons.favorite : Icons.favorite_border, + iconColor: isLoved ? colorScheme.error : null, + title: isLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToLoved(track.name) + : context.l10n.collectionRemovedFromLoved(track.name), + ), + ), + ); + }, + ), + _OptionTile( + icon: isInWishlist + ? Icons.playlist_add_check_circle + : Icons.add_circle_outline, + iconColor: isInWishlist ? colorScheme.primary : null, + title: isInWishlist + ? context.l10n.trackOptionRemoveFromWishlist + : context.l10n.trackOptionAddToWishlist, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleWishlist(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToWishlist(track.name) + : context.l10n.collectionRemovedFromWishlist( + track.name), + ), + ), + ); + }, + ), + _OptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(context); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + const SizedBox(height: 16), + ], + ), + ); + } +} + +/// Styled like _QualityOption in download_service_picker.dart +class _OptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _OptionTile({ + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} From e39756fa3fb7f7610450e617c56fabe913c354df Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 16:40:03 +0700 Subject: [PATCH 13/38] refactor: migrate persistence to SQLite, add strict provider mode, and optimize collection lookups - Replace SharedPreferences with SQLite (AppStateDatabase, LibraryCollectionsDatabase) for download queue, library collections, and recent access history - Add Set-based O(1) track containment checks for wishlist, loved, and playlist tracks - Add batch addTracksToPlaylist with PlaylistAddBatchResult - Go backend: strict mode locks download to selected provider when auto fallback is off - Go backend: fix extension progress normalization (percent/100) and lifecycle tracking - Go backend: case-insensitive provider ID matching throughout fallback chain - Lyrics embedding now respects lyricsMode setting (embed/both/off) - Debounced queue persistence to reduce write frequency - Fix shouldUseFallback logic to not be gated by useExtensions --- go_backend/exports.go | 32 +- go_backend/extension_providers.go | 79 ++- go_backend/extension_runtime.go | 1 - go_backend/extension_store.go | 2 - go_backend/songlink.go | 16 - lib/providers/download_queue_provider.dart | 153 ++++-- .../library_collections_provider.dart | 489 +++++++++++++----- lib/providers/recent_access_provider.dart | 187 +++---- lib/screens/library_playlists_screen.dart | 125 ++--- lib/screens/library_tracks_folder_screen.dart | 121 ++--- lib/screens/queue_tab.dart | 269 +++++----- lib/screens/track_metadata_screen.dart | 7 +- lib/services/app_state_database.dart | 309 +++++++++++ .../library_collections_database.dart | 411 +++++++++++++++ lib/widgets/playlist_picker_sheet.dart | 4 +- 15 files changed, 1581 insertions(+), 624 deletions(-) create mode 100644 lib/services/app_state_database.dart create mode 100644 lib/services/library_collections_database.dart diff --git a/go_backend/exports.go b/go_backend/exports.go index 268184b3..dbae733e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1,5 +1,3 @@ -// Package gobackend provides exported functions for gomobile binding -// These functions are the bridge between Flutter and Go backend package gobackend import ( @@ -464,8 +462,8 @@ func DownloadTrack(requestJSON string) (string, error) { if youtubeErr == nil { result = DownloadResult{ FilePath: youtubeResult.FilePath, - BitDepth: 0, // Lossy format, no bit depth - SampleRate: 0, // Lossy format + BitDepth: 0, + SampleRate: 0, Title: youtubeResult.Title, Artist: youtubeResult.Artist, Album: youtubeResult.Album, @@ -543,6 +541,11 @@ func DownloadByStrategy(requestJSON string) (string, error) { } if req.UseExtensions { + // Respect strict mode when auto fallback is disabled: + // for built-in providers, route directly to selected service only. + if !req.UseFallback && isBuiltInProvider(serviceNormalized) { + return DownloadTrack(normalizedJSON) + } resp, err := DownloadWithExtensionsJSON(normalizedJSON) if err != nil { return errorResponse(err.Error()) @@ -916,7 +919,6 @@ func SetDownloadDirectory(path string) error { return setDownloadDir(path) } -// AllowDownloadDir adds a directory to the extension file sandbox allowlist. func AllowDownloadDir(path string) { if strings.TrimSpace(path) == "" { return @@ -1524,11 +1526,6 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } -// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ==================== - -// DownloadFromYouTube downloads a track from YouTube via Cobalt API -// This is a lossy-only provider (Opus/MP3 with configurable bitrate) -// It does NOT participate in the lossless fallback chain func DownloadFromYouTube(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -1575,20 +1572,14 @@ func DownloadFromYouTube(requestJSON string) (string, error) { return string(jsonBytes), nil } -// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter) func IsYouTubeURLExport(urlStr string) bool { return IsYouTubeURL(urlStr) } -// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter) func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { return ExtractYouTubeVideoID(urlStr) } -// ==================== COVER & LYRICS SAVE ==================== - -// DownloadCoverToFile downloads cover art from URL and saves to outputPath. -// If maxQuality is true, upgrades to highest available resolution. func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") @@ -1607,7 +1598,6 @@ func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) er return nil } -// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath. func ExtractCoverToFile(audioPath string, outputPath string) error { lower := strings.ToLower(audioPath) @@ -1636,7 +1626,6 @@ func ExtractCoverToFile(audioPath string, outputPath string) error { return nil } -// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file. func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error { client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 @@ -1663,9 +1652,6 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6 return nil } -// ==================== LYRICS PROVIDER SETTINGS ==================== - -// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs. func SetLyricsProvidersJSON(providersJSON string) error { var providers []string if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil { @@ -1676,7 +1662,6 @@ func SetLyricsProvidersJSON(providersJSON string) error { return nil } -// GetLyricsProvidersJSON returns the current lyrics provider order as JSON. func GetLyricsProvidersJSON() (string, error) { providers := GetLyricsProviderOrder() jsonBytes, err := json.Marshal(providers) @@ -1686,7 +1671,6 @@ func GetLyricsProvidersJSON() (string, error) { return string(jsonBytes), nil } -// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers. func GetAvailableLyricsProvidersJSON() (string, error) { providers := GetAvailableLyricsProviders() jsonBytes, err := json.Marshal(providers) @@ -1696,7 +1680,6 @@ func GetAvailableLyricsProvidersJSON() (string, error) { return string(jsonBytes), nil } -// SetLyricsFetchOptionsJSON sets lyrics provider fetch options. func SetLyricsFetchOptionsJSON(optionsJSON string) error { opts := GetLyricsFetchOptions() if strings.TrimSpace(optionsJSON) != "" { @@ -1709,7 +1692,6 @@ func SetLyricsFetchOptionsJSON(optionsJSON string) error { return nil } -// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options. func GetLyricsFetchOptionsJSON() (string, error) { opts := GetLyricsFetchOptions() jsonBytes, err := json.Marshal(opts) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index daf09111..694354b3 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension provider interfaces package gobackend import ( @@ -15,9 +14,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Metadata Types ==================== - -// ExtTrackMetadata represents track metadata from an extension type ExtTrackMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -675,8 +671,20 @@ func isBuiltInProvider(providerID string) bool { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { priority := GetProviderPriority() extManager := GetExtensionManager() + strictMode := !req.UseFallback + selectedProvider := strings.TrimSpace(req.Service) - if req.Service != "" && isBuiltInProvider(req.Service) { + if strictMode { + if selectedProvider == "" { + selectedProvider = strings.TrimSpace(req.Source) + } + if selectedProvider != "" { + priority = []string{selectedProvider} + GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider) + } + } + + if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) { GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service) newPriority := []string{req.Service} for _, p := range priority { @@ -691,7 +699,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro var lastErr error var skipBuiltIn bool - if req.Source != "" && !isBuiltInProvider(req.Source) { + if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) { ext, err := extManager.GetExtension(req.Source) if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) @@ -754,7 +762,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - if req.Source != "" && !isBuiltInProvider(req.Source) { + if req.Source != "" && + !isBuiltInProvider(strings.ToLower(req.Source)) && + (!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) { GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) ext, err := extManager.GetExtension(req.Source) @@ -768,12 +778,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) outputPath := buildOutputPath(req) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { if req.ItemID != "" { - SetItemProgress(req.ItemID, float64(percent), 0, 0) + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) } }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } if err == nil && result.Success { resp := &DownloadResponse{ @@ -860,18 +887,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } for _, providerID := range priority { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + providerIDNormalized := strings.ToLower(providerID) if providerID == req.Source { continue } - if skipBuiltIn && isBuiltInProvider(providerID) { + if skipBuiltIn && isBuiltInProvider(providerIDNormalized) { GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) continue } GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) - if isBuiltInProvider(providerID) { + if isBuiltInProvider(providerIDNormalized) { if (req.Genre == "" || req.Label == "") && req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -892,9 +924,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - result, err := tryBuiltInProvider(providerID, req) + result, err := tryBuiltInProvider(providerIDNormalized, req) if err == nil && result.Success { - result.Service = providerID + result.Service = providerIDNormalized if req.Label != "" { result.Label = req.Label } @@ -915,11 +947,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Success: false, Error: "Download cancelled", ErrorType: "cancelled", - Service: providerID, + Service: providerIDNormalized, }, nil } lastErr = err - GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerIDNormalized, err) } } else { ext, err := extManager.GetExtension(providerID) @@ -944,12 +976,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } outputPath := buildOutputPath(req) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { if req.ItemID != "" { - SetItemProgress(req.ItemID, float64(percent), 0, 0) + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) } }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } if err == nil && result.Success { resp := &DownloadResponse{ diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 9f87b39d..2e33d227 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -145,7 +145,6 @@ func (e *RedirectBlockedError) Error() string { return "redirect blocked: domain '" + e.Domain + "' not in allowed list" } -// isPrivateIP checks if a hostname resolves to a private/local IP address func isPrivateIP(host string) bool { hostLower := strings.ToLower(strings.TrimSpace(host)) if hostLower == "" { diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 6195907a..2fa17e84 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -77,7 +77,6 @@ type StoreRegistry struct { Extensions []StoreExtension `json:"extensions"` } -// StoreExtensionResponse is the normalized response sent to Flutter type StoreExtensionResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -421,7 +420,6 @@ func (s *ExtensionStore) ClearCache() { LogInfo("ExtensionStore", "Cache cleared") } -// Helper: case-insensitive contains func containsIgnoreCase(s, substr string) bool { return containsStr(toLower(s), substr) } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ecc9e35a..62f926f8 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -182,7 +182,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str return urls, nil } -// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL func extractDeezerIDFromURL(deezerURL string) string { parts := strings.Split(deezerURL, "/") if len(parts) > 0 { @@ -260,10 +259,6 @@ func extractQobuzIDFromURL(qobuzURL string) string { return "" } -// extractTidalIDFromURL extracts Tidal track ID from URL -// URL formats: -// - https://tidal.com/browse/track/12345678 -// - https://listen.tidal.com/track/12345678 func extractTidalIDFromURL(tidalURL string) string { if tidalURL == "" { return "" @@ -289,11 +284,6 @@ func extractTidalIDFromURL(tidalURL string) string { return "" } -// extractYouTubeIDFromURL extracts YouTube video ID from URL -// URL formats: -// - https://www.youtube.com/watch?v=VIDEO_ID -// - https://youtu.be/VIDEO_ID -// - https://music.youtube.com/watch?v=VIDEO_ID func extractYouTubeIDFromURL(youtubeURL string) string { if youtubeURL == "" { return "" @@ -350,7 +340,6 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, return availability.DeezerID, nil } -// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -364,7 +353,6 @@ func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string return availability.YouTubeURL, nil } -// AlbumAvailability represents album availability on different platforms type AlbumAvailability struct { SpotifyID string `json:"spotify_id"` Deezer bool `json:"deezer"` @@ -422,7 +410,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv return availability, nil } -// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) { availability, err := s.CheckAlbumAvailability(spotifyAlbumID) if err != nil { @@ -652,7 +639,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit return availability, nil } -// extractSpotifyIDFromURL extracts Spotify track ID from URL func extractSpotifyIDFromURL(spotifyURL string) string { parts := strings.Split(spotifyURL, "/track/") if len(parts) > 1 { @@ -678,7 +664,6 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e return availability.SpotifyID, nil } -// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { @@ -705,7 +690,6 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e return availability.AmazonURL, nil } -// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4da9e3e5..a41f5484 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4,13 +4,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -691,14 +691,15 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; + Timer? _queuePersistDebounce; int _downloadCount = 0; static const _cleanupInterval = 50; - static const _queueStorageKey = 'download_queue'; static const _progressPollingInterval = Duration(milliseconds: 800); static const _queueSchedulingInterval = Duration(milliseconds: 250); + static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. final NotificationService _notificationService = NotificationService(); - final Future _prefs = SharedPreferences.getInstance(); + final AppStateDatabase _appStateDb = AppStateDatabase.instance; int _totalQueuedAtStart = 0; int _completedInSession = 0; int _failedInSession = 0; @@ -777,6 +778,13 @@ class DownloadQueueNotifier extends Notifier { ref.onDispose(() { _progressTimer?.cancel(); _progressTimer = null; + if (_queuePersistDebounce?.isActive == true) { + _queuePersistDebounce?.cancel(); + unawaited(_flushQueueToStorage()); + } else { + _queuePersistDebounce?.cancel(); + } + _queuePersistDebounce = null; }); Future.microtask(() async { @@ -792,46 +800,56 @@ class DownloadQueueNotifier extends Notifier { _isLoaded = true; try { - final prefs = await _prefs; - final jsonStr = prefs.getString(_queueStorageKey); - if (jsonStr != null && jsonStr.isNotEmpty) { - final List jsonList = jsonDecode(jsonStr); - final items = jsonList - .map((e) => DownloadItem.fromJson(e as Map)) - .toList(); - - final restoredItems = items.map((item) { - if (item.status == DownloadStatus.downloading) { - return item.copyWith(status: DownloadStatus.queued, progress: 0); - } - return item; - }).toList(); - - final pendingItems = restoredItems - .where((item) => item.status == DownloadStatus.queued) - .toList(); - - if (pendingItems.isNotEmpty) { - state = state.copyWith(items: pendingItems); - _log.i('Restored ${pendingItems.length} pending items from storage'); - - Future.microtask(() => _processQueue()); - } else { - _log.d('No pending items to restore'); - await prefs.remove(_queueStorageKey); - } - } else { + await _appStateDb.migrateQueueFromSharedPreferences(); + final rows = await _appStateDb.getPendingDownloadQueueRows(); + if (rows.isEmpty) { _log.d('No queue found in storage'); + return; } + + final pendingItems = []; + for (final row in rows) { + final itemJson = row['item_json'] as String?; + if (itemJson == null || itemJson.isEmpty) continue; + + try { + final decoded = jsonDecode(itemJson); + if (decoded is! Map) continue; + var item = DownloadItem.fromJson(Map.from(decoded)); + if (item.status == DownloadStatus.downloading) { + item = item.copyWith(status: DownloadStatus.queued, progress: 0); + } + if (item.status == DownloadStatus.queued) { + pendingItems.add(item); + } + } catch (_) { + continue; + } + } + + if (pendingItems.isEmpty) { + _log.d('No pending items to restore'); + await _appStateDb.replacePendingDownloadQueueRows(const []); + return; + } + + state = state.copyWith(items: pendingItems); + _log.i('Restored ${pendingItems.length} pending items from storage'); + Future.microtask(() => _processQueue()); } catch (e) { _log.e('Failed to load queue from storage: $e'); } } - Future _saveQueueToStorage() async { - try { - final prefs = await _prefs; + void _saveQueueToStorage() { + _queuePersistDebounce?.cancel(); + _queuePersistDebounce = Timer(_queuePersistDebounceDuration, () { + _flushQueueToStorage(); + }); + } + Future _flushQueueToStorage() async { + try { final pendingItems = state.items .where( (item) => @@ -841,11 +859,22 @@ class DownloadQueueNotifier extends Notifier { .toList(); if (pendingItems.isEmpty) { - await prefs.remove(_queueStorageKey); + await _appStateDb.replacePendingDownloadQueueRows(const []); _log.d('Cleared queue storage (no pending items)'); } else { - final jsonList = pendingItems.map((e) => e.toJson()).toList(); - await prefs.setString(_queueStorageKey, jsonEncode(jsonList)); + final nowIso = DateTime.now().toIso8601String(); + final rows = pendingItems + .map( + (item) => { + 'id': item.id, + 'item_json': jsonEncode(item.toJson()), + 'status': item.status.name, + 'created_at': item.createdAt.toIso8601String(), + 'updated_at': nowIso, + }, + ) + .toList(growable: false); + await _appStateDb.replacePendingDownloadQueueRows(rows); _log.d('Saved ${pendingItems.length} pending items to storage'); } } catch (e) { @@ -1977,26 +2006,37 @@ class DownloadQueueNotifier extends Notifier { _log.d('Metadata map content: $metadata'); - try { - final durationMs = track.duration * 1000; + final lyricsMode = settings.lyricsMode; + final shouldEmbedLyrics = + settings.embedLyrics && + (lyricsMode == 'embed' || lyricsMode == 'both'); - final lrcContent = await PlatformBridge.getLyricsLRC( - track.id, - track.name, - track.artistName, - filePath: '', - durationMs: durationMs, - ); + if (shouldEmbedLyrics) { + try { + final durationMs = track.duration * 1000; - if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { - metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); - } else if (lrcContent == '[instrumental:true]') { - _log.d('Track is instrumental, skipping lyrics embedding'); + final lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: durationMs, + ); + + if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); + } else if (lrcContent == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics embedding'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for embedding: $e'); } - } catch (e) { - _log.w('Failed to fetch lyrics for embedding: $e'); + } else { + metadata['LYRICS'] = ''; + metadata['UNSYNCEDLYRICS'] = ''; + _log.d('Lyrics embedding disabled by settings, skipping lyric fetch'); } _log.d('Generating tags for FLAC: $metadata'); @@ -3012,8 +3052,7 @@ class DownloadQueueNotifier extends Notifier { final outputExt = useSaf ? safOutputExt : ''; final isYouTube = item.service == 'youtube'; final shouldUseExtensions = !isYouTube && useExtensions; - final shouldUseFallback = - !isYouTube && !shouldUseExtensions && state.autoFallback; + final shouldUseFallback = !isYouTube && state.autoFallback; if (isYouTube) { _log.d('Using YouTube/Cobalt provider for download'); diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index e1d7ca1f..485e3482 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -4,10 +4,8 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/track.dart'; - -const _collectionsStorageKey = 'library_collections_v1'; +import 'package:spotiflac_android/services/library_collections_database.dart'; String trackCollectionKey(Track track) { final isrc = track.isrc?.trim(); @@ -54,15 +52,17 @@ class UserPlaylistCollection { final DateTime createdAt; final DateTime updatedAt; final List tracks; + final Set _trackKeys; - const UserPlaylistCollection({ + UserPlaylistCollection({ required this.id, required this.name, this.coverImagePath, required this.createdAt, required this.updatedAt, required this.tracks, - }); + Set? trackKeys, + }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); UserPlaylistCollection copyWith({ String? id, @@ -72,20 +72,28 @@ class UserPlaylistCollection { DateTime? updatedAt, List? tracks, }) { + final nextTracks = tracks ?? this.tracks; + final keepTrackIndex = identical(nextTracks, this.tracks); return UserPlaylistCollection( id: id ?? this.id, name: name ?? this.name, - coverImagePath: - coverImagePath != null ? coverImagePath() : this.coverImagePath, + coverImagePath: coverImagePath != null + ? coverImagePath() + : this.coverImagePath, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - tracks: tracks ?? this.tracks, + tracks: nextTracks, + trackKeys: keepTrackIndex ? _trackKeys : null, ); } bool containsTrack(Track track) { final key = trackCollectionKey(track); - return tracks.any((entry) => entry.key == key); + return _trackKeys.contains(key); + } + + bool containsTrackKey(String trackKey) { + return _trackKeys.contains(trackKey); } Map toJson() => { @@ -124,13 +132,26 @@ class LibraryCollectionsState { final List loved; final List playlists; final bool isLoaded; + final Set _wishlistKeys; + final Set _lovedKeys; + final Map _playlistsById; - const LibraryCollectionsState({ + LibraryCollectionsState({ this.wishlist = const [], this.loved = const [], this.playlists = const [], this.isLoaded = false, - }); + Set? wishlistKeys, + Set? lovedKeys, + Map? playlistsById, + }) : _wishlistKeys = + wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), + _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), + _playlistsById = + playlistsById ?? + Map.fromEntries( + playlists.map((playlist) => MapEntry(playlist.id, playlist)), + ); int get wishlistCount => wishlist.length; int get lovedCount => loved.length; @@ -138,19 +159,30 @@ class LibraryCollectionsState { bool isInWishlist(Track track) { final key = trackCollectionKey(track); - return wishlist.any((entry) => entry.key == key); + return _wishlistKeys.contains(key); } bool isLoved(Track track) { final key = trackCollectionKey(track); - return loved.any((entry) => entry.key == key); + return _lovedKeys.contains(key); + } + + bool containsWishlistKey(String trackKey) { + return _wishlistKeys.contains(trackKey); + } + + bool containsLovedKey(String trackKey) { + return _lovedKeys.contains(trackKey); } UserPlaylistCollection? playlistById(String playlistId) { - for (final playlist in playlists) { - if (playlist.id == playlistId) return playlist; - } - return null; + return _playlistsById[playlistId]; + } + + bool playlistContainsTrack(String playlistId, String trackKey) { + final playlist = _playlistsById[playlistId]; + if (playlist == null) return false; + return playlist.containsTrackKey(trackKey); } LibraryCollectionsState copyWith({ @@ -159,11 +191,21 @@ class LibraryCollectionsState { List? playlists, bool? isLoaded, }) { + final nextWishlist = wishlist ?? this.wishlist; + final nextLoved = loved ?? this.loved; + final nextPlaylists = playlists ?? this.playlists; + final keepWishlistIndex = identical(nextWishlist, this.wishlist); + final keepLovedIndex = identical(nextLoved, this.loved); + final keepPlaylistIndex = identical(nextPlaylists, this.playlists); + return LibraryCollectionsState( - wishlist: wishlist ?? this.wishlist, - loved: loved ?? this.loved, - playlists: playlists ?? this.playlists, + wishlist: nextWishlist, + loved: nextLoved, + playlists: nextPlaylists, isLoaded: isLoaded ?? this.isLoaded, + wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, + lovedKeys: keepLovedIndex ? _lovedKeys : null, + playlistsById: keepPlaylistIndex ? _playlistsById : null, ); } @@ -203,56 +245,145 @@ class LibraryCollectionsState { } } +class PlaylistAddBatchResult { + final int addedCount; + final int alreadyInPlaylistCount; + + const PlaylistAddBatchResult({ + required this.addedCount, + required this.alreadyInPlaylistCount, + }); +} + class LibraryCollectionsNotifier extends Notifier { - final Future _prefs = SharedPreferences.getInstance(); + final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; Future? _loadFuture; @override LibraryCollectionsState build() { _loadFuture = _load(); - return const LibraryCollectionsState(); + return LibraryCollectionsState(); } Future _load() async { - final prefs = await _prefs; - final raw = prefs.getString(_collectionsStorageKey); - - if (raw == null || raw.isEmpty) { - state = state.copyWith(isLoaded: true); - return; - } - try { - final parsed = jsonDecode(raw); - if (parsed is Map) { - state = LibraryCollectionsState.fromJson(parsed); - } else { - state = state.copyWith(isLoaded: true); + await _db.migrateFromSharedPreferences(); + final snapshot = await _db.loadSnapshot(); + + final wishlist = []; + for (final row in snapshot.wishlistRows) { + final parsed = _parseTrackEntryRow(row); + if (parsed != null) { + wishlist.add(parsed); + } } + + final loved = []; + for (final row in snapshot.lovedRows) { + final parsed = _parseTrackEntryRow(row); + if (parsed != null) { + loved.add(parsed); + } + } + + final tracksByPlaylist = >{}; + for (final row in snapshot.playlistTrackRows) { + final playlistId = row['playlist_id'] as String?; + if (playlistId == null || playlistId.isEmpty) continue; + final parsed = _parseTrackEntryRow(row); + if (parsed == null) continue; + tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed); + } + + final playlists = []; + for (final row in snapshot.playlistRows) { + final id = row['id'] as String?; + if (id == null || id.isEmpty) continue; + + final createdAtRaw = row['created_at'] as String?; + final updatedAtRaw = row['updated_at'] as String?; + final createdAt = + DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + + playlists.add( + UserPlaylistCollection( + id: id, + name: row['name'] as String? ?? '', + coverImagePath: row['cover_image_path'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksByPlaylist[id] ?? const [], + ), + ); + } + + state = LibraryCollectionsState( + wishlist: wishlist, + loved: loved, + playlists: playlists, + isLoaded: true, + ); } catch (_) { state = state.copyWith(isLoaded: true); } } - Future _save() async { - final prefs = await _prefs; - await prefs.setString(_collectionsStorageKey, jsonEncode(state.toJson())); - } - Future _ensureLoaded() async { if (state.isLoaded) return; await (_loadFuture ?? _load()); } + CollectionTrackEntry? _parseTrackEntryRow(Map row) { + final key = row['track_key'] as String?; + final trackJson = row['track_json'] as String?; + if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(trackJson); + if (decoded is! Map) return null; + final track = Track.fromJson(Map.from(decoded)); + final addedAtRaw = row['added_at'] as String?; + return CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } catch (_) { + return null; + } + } + + bool _replacePlaylistById( + String playlistId, + UserPlaylistCollection Function(UserPlaylistCollection playlist) update, + ) { + final playlist = state.playlistById(playlistId); + if (playlist == null) return false; + + final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); + if (playlistIndex < 0) return false; + + final nextPlaylist = update(playlist); + if (identical(nextPlaylist, playlist)) return false; + + final updatedPlaylists = [...state.playlists]; + updatedPlaylists[playlistIndex] = nextPlaylist; + state = state.copyWith(playlists: updatedPlaylists); + return true; + } + Future toggleWishlist(Track track) async { await _ensureLoaded(); final key = trackCollectionKey(track); - final index = state.wishlist.indexWhere((entry) => entry.key == key); - - if (index >= 0) { - final updated = [...state.wishlist]..removeAt(index); + if (state.containsWishlistKey(key)) { + await _db.deleteWishlistEntry(key); + final updated = state.wishlist + .where((entry) => entry.key != key) + .toList(growable: false); state = state.copyWith(wishlist: updated); - await _save(); return false; } @@ -261,21 +392,25 @@ class LibraryCollectionsNotifier extends Notifier { track: track, addedAt: DateTime.now(), ); + await _db.upsertWishlistEntry( + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); final updated = [entry, ...state.wishlist]; state = state.copyWith(wishlist: updated); - await _save(); return true; } Future toggleLoved(Track track) async { await _ensureLoaded(); final key = trackCollectionKey(track); - final index = state.loved.indexWhere((entry) => entry.key == key); - - if (index >= 0) { - final updated = [...state.loved]..removeAt(index); + if (state.containsLovedKey(key)) { + await _db.deleteLovedEntry(key); + final updated = state.loved + .where((entry) => entry.key != key) + .toList(growable: false); state = state.copyWith(loved: updated); - await _save(); return false; } @@ -284,30 +419,36 @@ class LibraryCollectionsNotifier extends Notifier { track: track, addedAt: DateTime.now(), ); + await _db.upsertLovedEntry( + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); final updated = [entry, ...state.loved]; state = state.copyWith(loved: updated); - await _save(); return true; } Future removeFromWishlist(String trackKey) async { await _ensureLoaded(); + if (!state.containsWishlistKey(trackKey)) return; + + await _db.deleteWishlistEntry(trackKey); final updated = state.wishlist .where((entry) => entry.key != trackKey) .toList(growable: false); - if (updated.length == state.wishlist.length) return; state = state.copyWith(wishlist: updated); - await _save(); } Future removeFromLoved(String trackKey) async { await _ensureLoaded(); + if (!state.containsLovedKey(trackKey)) return; + + await _db.deleteLovedEntry(trackKey); final updated = state.loved .where((entry) => entry.key != trackKey) .toList(growable: false); - if (updated.length == state.loved.length) return; state = state.copyWith(loved: updated); - await _save(); } Future createPlaylist(String name) async { @@ -324,8 +465,14 @@ class LibraryCollectionsNotifier extends Notifier { tracks: const [], ); + await _db.upsertPlaylist( + id: id, + name: trimmedName, + coverImagePath: null, + createdAt: now.toIso8601String(), + updatedAt: now.toIso8601String(), + ); state = state.copyWith(playlists: [playlist, ...state.playlists]); - await _save(); return id; } @@ -333,90 +480,149 @@ class LibraryCollectionsNotifier extends Notifier { await _ensureLoaded(); final trimmed = newName.trim(); if (trimmed.isEmpty) return; + final playlist = state.playlistById(playlistId); + if (playlist == null || playlist.name == trimmed) return; final now = DateTime.now(); - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - return playlist.copyWith(name: trimmed, updatedAt: now); - }) - .toList(growable: false); - - state = state.copyWith(playlists: updated); - await _save(); + await _db.renamePlaylist( + playlistId: playlistId, + name: trimmed, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + return playlist.copyWith(name: trimmed, updatedAt: now); + }); } Future deletePlaylist(String playlistId) async { await _ensureLoaded(); - final updated = state.playlists - .where((playlist) => playlist.id != playlistId) - .toList(growable: false); - if (updated.length == state.playlists.length) return; - state = state.copyWith(playlists: updated); - await _save(); + final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); + if (playlistIndex < 0) return; + + await _db.deletePlaylist(playlistId); + final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); + state = state.copyWith(playlists: updatedPlaylists); } Future addTrackToPlaylist(String playlistId, Track track) async { await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return false; + final key = trackCollectionKey(track); + if (playlist.containsTrackKey(key)) return false; + final now = DateTime.now(); - var changed = false; - - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - final alreadyInPlaylist = playlist.tracks.any( - (entry) => entry.key == key, - ); - if (alreadyInPlaylist) return playlist; - changed = true; - final entry = CollectionTrackEntry( - key: key, - track: track, - addedAt: now, - ); - return playlist.copyWith( - tracks: [entry, ...playlist.tracks], - updatedAt: now, - ); - }) - .toList(growable: false); - + final entry = CollectionTrackEntry(key: key, track: track, addedAt: now); + await _db.upsertPlaylistTrack( + playlistId: playlistId, + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + playlistUpdatedAt: now.toIso8601String(), + ); + final changed = _replacePlaylistById(playlistId, (playlist) { + if (playlist.containsTrackKey(key)) return playlist; + return playlist.copyWith( + tracks: [entry, ...playlist.tracks], + updatedAt: now, + ); + }); if (!changed) return false; - - state = state.copyWith(playlists: updated); - await _save(); return true; } + Future addTracksToPlaylist( + String playlistId, + Iterable tracks, + ) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) { + return const PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: 0, + ); + } + + final now = DateTime.now(); + final knownKeys = {...playlist._trackKeys}; + final entriesToAdd = []; + var alreadyInPlaylistCount = 0; + + for (final track in tracks) { + final key = trackCollectionKey(track); + if (!knownKeys.add(key)) { + alreadyInPlaylistCount++; + continue; + } + + entriesToAdd.add( + CollectionTrackEntry(key: key, track: track, addedAt: now), + ); + } + + if (entriesToAdd.isEmpty) { + return PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + + await _db.upsertPlaylistTracksBatch( + playlistId: playlistId, + playlistUpdatedAt: now.toIso8601String(), + tracks: entriesToAdd + .map( + (entry) => { + 'track_key': entry.key, + 'track_json': jsonEncode(entry.track.toJson()), + 'added_at': entry.addedAt.toIso8601String(), + }, + ) + .toList(growable: false), + ); + final changed = _replacePlaylistById(playlistId, (current) { + return current.copyWith( + tracks: [...entriesToAdd.reversed, ...current.tracks], + updatedAt: now, + ); + }); + if (!changed) { + return PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + return PlaylistAddBatchResult( + addedCount: entriesToAdd.length, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + Future removeTrackFromPlaylist( String playlistId, String trackKey, ) async { await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null || !playlist.containsTrackKey(trackKey)) return; + final now = DateTime.now(); - var changed = false; - - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - final nextTracks = playlist.tracks - .where((entry) => entry.key != trackKey) - .toList(growable: false); - if (nextTracks.length == playlist.tracks.length) return playlist; - changed = true; - return playlist.copyWith(tracks: nextTracks, updatedAt: now); - }) - .toList(growable: false); - - if (!changed) return; - - state = state.copyWith(playlists: updated); - await _save(); + await _db.deletePlaylistTrack( + playlistId: playlistId, + trackKey: trackKey, + playlistUpdatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + final nextTracks = playlist.tracks + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (nextTracks.length == playlist.tracks.length) return playlist; + return playlist.copyWith(tracks: nextTracks, updatedAt: now); + }); } - /// Returns the directory for storing playlist cover images, creating it - /// if necessary. Future _playlistCoversDir() async { final appDir = await getApplicationSupportDirectory(); final dir = Directory(p.join(appDir.path, 'playlist_covers')); @@ -426,41 +632,38 @@ class LibraryCollectionsNotifier extends Notifier { return dir; } - /// Sets a custom cover image for a playlist by copying the source file - /// into the app's persistent storage. Future setPlaylistCover( String playlistId, String sourceFilePath, ) async { await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return; + final coversDir = await _playlistCoversDir(); final ext = p.extension(sourceFilePath).toLowerCase(); final destPath = p.join(coversDir.path, '$playlistId$ext'); + if (playlist.coverImagePath == destPath) return; // Copy image to persistent location await File(sourceFilePath).copy(destPath); final now = DateTime.now(); - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - return playlist.copyWith( - coverImagePath: () => destPath, - updatedAt: now, - ); - }) - .toList(growable: false); - - state = state.copyWith(playlists: updated); - await _save(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: destPath, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == destPath) return playlist; + return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); + }); } - /// Removes the custom cover image for a playlist (falls back to first - /// track's cover). Future removePlaylistCover(String playlistId) async { await _ensureLoaded(); final playlist = state.playlistById(playlistId); - if (playlist == null) return; + if (playlist == null || playlist.coverImagePath == null) return; // Delete the file if it exists final path = playlist.coverImagePath; @@ -472,15 +675,15 @@ class LibraryCollectionsNotifier extends Notifier { } final now = DateTime.now(); - final updated = state.playlists - .map((pl) { - if (pl.id != playlistId) return pl; - return pl.copyWith(coverImagePath: () => null, updatedAt: now); - }) - .toList(growable: false); - - state = state.copyWith(playlists: updated); - await _save(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: null, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == null) return playlist; + return playlist.copyWith(coverImagePath: () => null, updatedAt: now); + }); } } diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index e0475aef..8a2ba671 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -1,18 +1,12 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; -const _recentAccessKey = 'recent_access_history'; -const _hiddenDownloadsKey = 'hidden_downloads_in_recents'; const _maxRecentItems = 20; /// Types of items that can be accessed -enum RecentAccessType { - artist, - album, - track, - playlist, -} +enum RecentAccessType { artist, album, track, playlist } /// Represents a recently accessed item class RecentAccessItem { @@ -100,7 +94,7 @@ class RecentAccessState { /// Provider for managing recent access history class RecentAccessNotifier extends Notifier { - final Future _prefs = SharedPreferences.getInstance(); + final AppStateDatabase _appStateDb = AppStateDatabase.instance; @override RecentAccessState build() { @@ -109,40 +103,36 @@ class RecentAccessNotifier extends Notifier { } Future _loadHistory() async { - final prefs = await _prefs; - final json = prefs.getString(_recentAccessKey); - final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); - - List items = []; - Set hiddenIds = {}; - - if (json != null) { - try { - final List decoded = jsonDecode(json); - items = decoded - .map((e) => RecentAccessItem.fromJson(e as Map)) - .toList(); - } catch (_) { - // Ignore JSON parse errors, use empty list + try { + await _appStateDb.migrateRecentAccessFromSharedPreferences(); + final rows = await _appStateDb.getRecentAccessRows( + limit: _maxRecentItems, + ); + final hiddenIds = await _appStateDb.getHiddenRecentDownloadIds(); + + final items = []; + for (final row in rows) { + final itemJson = row['item_json'] as String?; + if (itemJson == null || itemJson.isEmpty) continue; + try { + final decoded = jsonDecode(itemJson); + if (decoded is! Map) continue; + items.add( + RecentAccessItem.fromJson(Map.from(decoded)), + ); + } catch (_) { + continue; + } } - } - - if (hiddenJson != null) { - hiddenIds = hiddenJson.toSet(); - } - - state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true); - } - Future _saveHistory() async { - final prefs = await _prefs; - final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); - await prefs.setString(_recentAccessKey, json); - } - - Future _saveHiddenDownloads() async { - final prefs = await _prefs; - await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); + state = state.copyWith( + items: items, + hiddenDownloadIds: hiddenIds, + isLoaded: true, + ); + } catch (_) { + state = state.copyWith(isLoaded: true); + } } /// Record an access to an artist @@ -152,14 +142,16 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - imageUrl: imageUrl, - type: RecentAccessType.artist, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + imageUrl: imageUrl, + type: RecentAccessType.artist, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to an album @@ -170,15 +162,17 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: artistName, - imageUrl: imageUrl, - type: RecentAccessType.album, - accessedAt: DateTime.now(), - providerId: 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 @@ -189,15 +183,17 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: artistName, - imageUrl: imageUrl, - type: RecentAccessType.track, - accessedAt: DateTime.now(), - providerId: 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 @@ -208,30 +204,42 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: ownerName, - imageUrl: imageUrl, - type: RecentAccessType.playlist, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: ownerName, + imageUrl: imageUrl, + type: RecentAccessType.playlist, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } void _recordAccess(RecentAccessItem item) { final updatedItems = state.items .where((e) => e.uniqueKey != item.uniqueKey) .toList(); - + updatedItems.insert(0, item); - + + RecentAccessItem? removedTail; if (updatedItems.length > _maxRecentItems) { - updatedItems.removeRange(_maxRecentItems, updatedItems.length); + removedTail = updatedItems.removeLast(); } - + state = state.copyWith(items: updatedItems); - _saveHistory(); + unawaited( + _appStateDb.upsertRecentAccessRow( + uniqueKey: item.uniqueKey, + itemJson: jsonEncode(item.toJson()), + accessedAt: item.accessedAt.toIso8601String(), + ), + ); + if (removedTail != null) { + unawaited(_appStateDb.deleteRecentAccessRow(removedTail.uniqueKey)); + } } /// Remove a specific item from history @@ -240,14 +248,14 @@ class RecentAccessNotifier extends Notifier { .where((e) => e.uniqueKey != item.uniqueKey) .toList(); state = state.copyWith(items: updatedItems); - _saveHistory(); + unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey)); } /// Hide a download item from recents (without deleting the actual download) void hideDownloadFromRecents(String downloadId) { final updatedHidden = {...state.hiddenDownloadIds, downloadId}; state = state.copyWith(hiddenDownloadIds: updatedHidden); - _saveHiddenDownloads(); + unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId)); } /// Check if a download is hidden from recents @@ -258,16 +266,17 @@ class RecentAccessNotifier extends Notifier { /// Clear all history void clearHistory() { state = state.copyWith(items: []); - _saveHistory(); + unawaited(_appStateDb.clearRecentAccessRows()); } /// Clear hidden downloads (show all again) void clearHiddenDownloads() { state = state.copyWith(hiddenDownloadIds: {}); - _saveHiddenDownloads(); + unawaited(_appStateDb.clearHiddenRecentDownloadIds()); } } -final recentAccessProvider = NotifierProvider( - RecentAccessNotifier.new, -); +final recentAccessProvider = + NotifierProvider( + RecentAccessNotifier.new, + ); diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index e1ad14b9..5a2eaf7f 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; class LibraryPlaylistsScreen extends ConsumerWidget { @@ -47,10 +48,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { return FlexibleSpaceBar( expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), title: Text( context.l10n.collectionPlaylists, style: TextStyle( @@ -87,10 +85,9 @@ class LibraryPlaylistsScreen extends ConsumerWidget { Text( context.l10n.collectionNoPlaylistsSubtitle, textAlign: TextAlign.center, - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -99,42 +96,39 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ) else SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - // Even indices = playlist tiles, odd indices = dividers - if (index.isOdd) { - return const Divider(height: 1); - } - final playlistIndex = index ~/ 2; - final playlist = playlists[playlistIndex]; - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 2, + delegate: SliverChildBuilderDelegate((context, index) { + // Even indices = playlist tiles, odd indices = dividers + if (index.isOdd) { + return const Divider(height: 1); + } + final playlistIndex = index ~/ 2; + final playlist = playlists[playlistIndex]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 2, + ), + leading: _buildPlaylistThumbnail(context, playlist), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, ), - leading: _buildPlaylistThumbnail(context, playlist), - title: Text(playlist.name), - subtitle: Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, - ), - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.playlist, - playlistId: playlist.id, - ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlist.id, ), - ); - }, - onLongPress: () => - _showPlaylistOptionsSheet(context, ref, playlist), - ); - }, - childCount: playlists.length * 2 - 1, - ), + ), + ); + }, + onLongPress: () => + _showPlaylistOptionsSheet(context, ref, playlist), + ); + }, childCount: playlists.length * 2 - 1), ), ], ), @@ -171,8 +165,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: 40, height: 4, decoration: BoxDecoration( - color: - colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2), ), ), @@ -188,9 +181,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { children: [ Text( playlist.name, - style: Theme.of(context) - .textTheme - .titleMedium + style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -200,9 +191,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { context.l10n.collectionPlaylistTracks( playlist.tracks.length, ), - style: Theme.of(context) - .textTheme - .bodyMedium + style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -291,12 +280,33 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ); } - final firstCoverUrl = playlist.tracks - .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) - .map((e) => e.track.coverUrl!) - .firstOrNull; + String? firstCoverUrl; + for (final entry in playlist.tracks) { + final coverUrl = entry.track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + firstCoverUrl = coverUrl; + break; + } + } if (firstCoverUrl != null) { + final isLocalPath = + !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + + if (isLocalPath) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + ), + ); + } + return ClipRRect( borderRadius: borderRadius, child: CachedNetworkImage( @@ -304,6 +314,8 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, placeholder: (_, _) => _playlistIconFallback(colorScheme, size), errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), ), @@ -321,10 +333,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon( - Icons.queue_music, - color: colorScheme.onSurfaceVariant, - ), + child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), ); } diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index e606460f..8968c851 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -9,7 +9,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; @@ -104,19 +104,34 @@ class _LibraryTracksFolderScreenState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final state = ref.watch(libraryCollectionsProvider); - final playlist = - widget.mode == LibraryTracksFolderMode.playlist && - widget.playlistId != null - ? state.playlistById(widget.playlistId!) - : null; + final UserPlaylistCollection? playlist; + final List entries; - final entries = switch (widget.mode) { - LibraryTracksFolderMode.wishlist => state.wishlist, - LibraryTracksFolderMode.loved => state.loved, - LibraryTracksFolderMode.playlist => - playlist?.tracks ?? const [], - }; + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + playlist = null; + entries = ref.watch( + libraryCollectionsProvider.select((state) => state.wishlist), + ); + break; + case LibraryTracksFolderMode.loved: + playlist = null; + entries = ref.watch( + libraryCollectionsProvider.select((state) => state.loved), + ); + break; + case LibraryTracksFolderMode.playlist: + final playlistId = widget.playlistId; + playlist = playlistId == null + ? null + : ref.watch( + libraryCollectionsProvider.select( + (state) => state.playlistById(playlistId), + ), + ); + entries = playlist?.tracks ?? const []; + break; + } final title = switch (widget.mode) { LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, @@ -157,10 +172,11 @@ class _LibraryTracksFolderScreenState ) else SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final entry = entries[index]; - return Column( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = entries[index]; + return KeyedSubtree( + key: ValueKey(entry.key), + child: Column( mainAxisSize: MainAxisSize.min, children: [ _CollectionTrackTile( @@ -168,13 +184,11 @@ class _LibraryTracksFolderScreenState mode: widget.mode, playlistId: widget.playlistId, ), - if (index < entries.length - 1) - const Divider(height: 1), + if (index < entries.length - 1) const Divider(height: 1), ], - ); - }, - childCount: entries.length, - ), + ), + ); + }, childCount: entries.length), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], @@ -299,8 +313,7 @@ class _LibraryTracksFolderScreenState Container(color: colorScheme.surface), ) : CachedNetworkImage( - imageUrl: - _highResCoverUrl(coverUrl) ?? coverUrl, + imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -541,9 +554,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), onTap: mode == LibraryTracksFolderMode.wishlist ? () => _downloadTrack(context, ref) - : mode == LibraryTracksFolderMode.playlist - ? () => _openInMusicPlayer(context, ref) - : null, + : () => _navigateToMetadata(context, ref), onLongPress: () => _showTrackOptionsSheet(context, ref), ); } @@ -613,8 +624,7 @@ class _CollectionTrackTile extends ConsumerWidget { width: 40, height: 4, decoration: BoxDecoration( - color: - colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2), ), ), @@ -624,8 +634,8 @@ class _CollectionTrackTile extends ConsumerWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && - track.coverUrl!.isNotEmpty + child: + track.coverUrl != null && track.coverUrl!.isNotEmpty ? _buildTrackCover(context, track.coverUrl!, 56) : Container( width: 56, @@ -644,9 +654,7 @@ class _CollectionTrackTile extends ConsumerWidget { children: [ Text( track.name, - style: Theme.of(context) - .textTheme - .titleMedium + style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -654,9 +662,7 @@ class _CollectionTrackTile extends ConsumerWidget { const SizedBox(height: 2), Text( track.artistName, - style: Theme.of(context) - .textTheme - .bodyMedium + style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -762,14 +768,12 @@ class _CollectionTrackTile extends ConsumerWidget { .read(downloadQueueProvider.notifier) .addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedToQueue(track.name)), - ), + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); } } - Future _openInMusicPlayer(BuildContext context, WidgetRef ref) async { + Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { final track = entry.track; final historyItem = ref .read(downloadHistoryProvider.notifier) @@ -777,29 +781,16 @@ class _CollectionTrackTile extends ConsumerWidget { if (historyItem == null) return; - final exists = await fileExists(historyItem.filePath); - if (!exists) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCannotOpenFile('File not found'), - ), - ), - ); - return; - } - - try { - await openFile(historyItem.filePath); - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), - ), - ); - } + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f26a8b2f..66efe2b6 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -28,10 +28,8 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; -/// Represents the source of a library item enum LibraryItemSource { downloaded, local } -/// Unified library item that can come from download history or local library class UnifiedLibraryItem { final String id; final String trackName; @@ -107,7 +105,6 @@ class UnifiedLibraryItem { ); } - /// Returns true if this item has a cover (either URL or local path) bool get hasCover => coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); @@ -209,7 +206,6 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } -/// Grouped album from local library class _GroupedLocalAlbum { final String albumName; final String artistName; @@ -251,10 +247,8 @@ class _HistoryStats { this.localSingleTracks = 0, }); - /// Total album count including local library int get totalAlbumCount => albumCount + localAlbumCount; - /// Total singles count including local library int get totalSingleTracks => singleTracks + localSingleTracks; } @@ -852,7 +846,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Bottom action bar for playlist selection mode. Widget _buildPlaylistSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -1252,7 +1245,6 @@ class _QueueTabState extends ConsumerState { return _applySorting(filtered); } - /// Apply current sort mode to a list of unified items List _applySorting(List items) { if (_sortMode == 'latest') { return items; // Already sorted newest first from _getUnifiedItems @@ -1275,7 +1267,6 @@ class _QueueTabState extends ConsumerState { return sorted; } - /// Check if a quality string passes the current quality filter bool _passesQualityFilter(String? quality) { if (_filterQuality == null) return true; if (quality == null) return _filterQuality == 'lossy'; @@ -1292,13 +1283,11 @@ class _QueueTabState extends ConsumerState { } } - /// Check if a file path passes the current format filter bool _passesFormatFilter(String filePath) { if (_filterFormat == null) return true; return _fileExtLower(filePath) == _filterFormat; } - /// Filter grouped download albums by search query + advanced filters List<_GroupedAlbum> _filterGroupedAlbums( List<_GroupedAlbum> albums, String searchQuery, @@ -1355,7 +1344,6 @@ class _QueueTabState extends ConsumerState { return result; } - /// Filter grouped local albums by search query + advanced filters List<_GroupedLocalAlbum> _filterGroupedLocalAlbums( List<_GroupedLocalAlbum> albums, String searchQuery, @@ -2085,8 +2073,7 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => - _playlistIconFallback(colorScheme, size), + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), ), ); } @@ -2098,7 +2085,8 @@ class _QueueTabState extends ConsumerState { if (firstCoverUrl != null) { // Guard against local file paths that may have been stored as coverUrl - final isLocalPath = !firstCoverUrl.startsWith('http://') && + final isLocalPath = + !firstCoverUrl.startsWith('http://') && !firstCoverUrl.startsWith('https://'); if (isLocalPath) { return ClipRRect( @@ -2108,8 +2096,7 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => - _playlistIconFallback(colorScheme, size), + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), ), ); } @@ -2120,10 +2107,8 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - placeholder: (_, _) => - _playlistIconFallback(colorScheme, size), - errorWidget: (_, _, _) => - _playlistIconFallback(colorScheme, size), + placeholder: (_, _) => _playlistIconFallback(colorScheme, size), + errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), ), ); } @@ -2174,26 +2159,21 @@ class _QueueTabState extends ConsumerState { selectedItems.add(item); } - int addedCount = 0; - int alreadyCount = 0; - for (final selected in selectedItems) { - final track = selected.toTrack(); - final added = await notifier.addTrackToPlaylist(playlistId, track); - if (added) { - addedCount++; - } else { - alreadyCount++; - } - } + final batchResult = await notifier.addTracksToPlaylist( + playlistId, + selectedItems.map((selected) => selected.toTrack()), + ); + final addedCount = batchResult.addedCount; + final alreadyCount = batchResult.alreadyInPlaylistCount; if (!context.mounted) return; final message = addedCount > 0 ? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName' - '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' + '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' : context.l10n.collectionAlreadyInPlaylist(playlistName); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); _exitSelectionMode(); return; } @@ -2221,7 +2201,8 @@ class _QueueTabState extends ConsumerState { UnifiedLibraryItem item, ColorScheme colorScheme, ) { - final isDraggingMultiple = _isSelectionMode && + final isDraggingMultiple = + _isSelectionMode && _selectedIds.contains(item.id) && _selectedIds.length > 1; final count = isDraggingMultiple ? _selectedIds.length : 1; @@ -2240,14 +2221,12 @@ class _QueueTabState extends ConsumerState { ConstrainedBox( constraints: const BoxConstraints(maxWidth: 180), child: Text( - isDraggingMultiple - ? '$count tracks' - : item.trackName, + isDraggingMultiple ? '$count tracks' : item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), ), ), ], @@ -2859,7 +2838,8 @@ class _QueueTabState extends ConsumerState { required VoidCallback onTap, VoidCallback? onLongPress, }) { - final cover = coverWidget ?? + final cover = + coverWidget ?? Container( width: 56, height: 56, @@ -2867,7 +2847,11 @@ class _QueueTabState extends ConsumerState { color: iconBgColor ?? colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 28), + child: Icon( + icon ?? Icons.folder, + color: iconColor ?? Colors.white, + size: 28, + ), ); return InkWell( @@ -2922,13 +2906,18 @@ class _QueueTabState extends ConsumerState { required VoidCallback onTap, VoidCallback? onLongPress, }) { - final cover = coverWidget ?? + final cover = + coverWidget ?? Container( decoration: BoxDecoration( color: iconBgColor ?? colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 40), + child: Icon( + icon ?? Icons.folder, + color: iconColor ?? Colors.white, + size: 40, + ), ); return GestureDetector( @@ -2949,9 +2938,9 @@ class _QueueTabState extends ConsumerState { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), Text( '$count ${count == 1 ? 'item' : 'items'}', @@ -3018,22 +3007,10 @@ class _QueueTabState extends ConsumerState { decoration: isHovering ? BoxDecoration( borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), + border: Border.all(color: colorScheme.primary, width: 2), color: colorScheme.primary.withValues(alpha: 0.1), ) - : isSelected - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), - color: colorScheme.primary.withValues(alpha: 0.08), - ) - : null, + : null, child: Stack( children: [ _buildCollectionGridItem( @@ -3067,8 +3044,11 @@ class _QueueTabState extends ConsumerState { ), ), child: isSelected - ? Icon(Icons.check, size: 16, - color: colorScheme.onPrimary) + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) : const SizedBox(width: 16, height: 16), ), ), @@ -3134,22 +3114,10 @@ class _QueueTabState extends ConsumerState { decoration: isHovering ? BoxDecoration( borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), + border: Border.all(color: colorScheme.primary, width: 2), color: colorScheme.primary.withValues(alpha: 0.1), ) - : isSelected - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), - color: colorScheme.primary.withValues(alpha: 0.08), - ) - : null, + : null, child: Row( children: [ if (_isPlaylistSelectionMode) @@ -3169,8 +3137,11 @@ class _QueueTabState extends ConsumerState { ), ), child: isSelected - ? Icon(Icons.check, size: 18, - color: colorScheme.onPrimary) + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) : const SizedBox(width: 18, height: 18), ), ), @@ -3268,7 +3239,6 @@ class _QueueTabState extends ConsumerState { // Collection folders as list items (Spotify-style) in "All" tab // are now rendered inline with tracks below (unified sliver) - if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && filterMode == 'albums') @@ -3422,18 +3392,69 @@ class _QueueTabState extends ConsumerState { SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final collectionCount = - 2 + collectionState.playlists.length; + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabGridCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ), + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final collectionCount = 2 + collectionState.playlists.length; if (index < collectionCount) { - return _buildAllTabGridCollectionItem( + return _buildAllTabListCollectionItem( context: context, colorScheme: colorScheme, index: index, @@ -3455,13 +3476,13 @@ class _QueueTabState extends ConsumerState { ), childWhenDragging: Opacity( opacity: 0.4, - child: _buildUnifiedGridItem( + child: _buildUnifiedLibraryItem( context, item, colorScheme, ), ), - child: _buildUnifiedGridItem( + child: _buildUnifiedLibraryItem( context, item, colorScheme, @@ -3475,57 +3496,6 @@ class _QueueTabState extends ConsumerState { 2 + collectionState.playlists.length + filteredUnifiedItems.length, - ), - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final collectionCount = - 2 + collectionState.playlists.length; - if (index < collectionCount) { - return _buildAllTabListCollectionItem( - context: context, - colorScheme: colorScheme, - index: index, - collectionState: collectionState, - filteredUnifiedItems: filteredUnifiedItems, - ); - } - final trackIndex = index - collectionCount; - if (trackIndex < filteredUnifiedItems.length) { - final item = filteredUnifiedItems[trackIndex]; - return KeyedSubtree( - key: ValueKey(item.id), - child: LongPressDraggable( - data: item, - feedback: _buildDragFeedback( - context, - item, - colorScheme, - ), - childWhenDragging: Opacity( - opacity: 0.4, - child: _buildUnifiedLibraryItem( - context, - item, - colorScheme, - ), - ), - child: _buildUnifiedLibraryItem( - context, - item, - colorScheme, - ), - ), - ); - } - return const SizedBox.shrink(); - }, - childCount: - 2 + - collectionState.playlists.length + - filteredUnifiedItems.length, ), ), ], @@ -5521,9 +5491,8 @@ class _QueueTabState extends ConsumerState { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, - ), + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), ), ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 29bf2b21..e5c70e8a 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -51,6 +51,7 @@ class _TrackMetadataScreenState extends ConsumerState { _embeddedCoverPreviewCache = {}; bool _fileExists = false; + bool _hasCheckedFile = false; int? _fileSize; String? _lyrics; // Cleaned lyrics for display (no timestamps) String? _rawLyrics; // Raw LRC with timestamps for embedding @@ -232,10 +233,12 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (_) {} - if (mounted && (exists != _fileExists || size != _fileSize)) { + if (mounted && + (exists != _fileExists || size != _fileSize || !_hasCheckedFile)) { setState(() { _fileExists = exists; _fileSize = size; + _hasCheckedFile = true; }); } @@ -818,7 +821,7 @@ class _TrackMetadataScreenState extends ConsumerState { ], ), ), - if (!_fileExists) + if (_hasCheckedFile && !_fileExists) Container( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart new file mode 100644 index 00000000..e5ca600c --- /dev/null +++ b/lib/services/app_state_database.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('AppStateDb'); + +const _dbFileName = 'app_state.db'; +const _dbVersion = 1; + +const _queueTable = 'download_queue_items'; +const _recentTable = 'recent_access_items'; +const _hiddenRecentTable = 'hidden_recent_downloads'; + +const _legacyQueueKey = 'download_queue'; +const _legacyRecentAccessKey = 'recent_access_history'; +const _legacyHiddenDownloadsKey = 'hidden_downloads_in_recents'; + +const _queueMigrationKey = 'app_state_migrated_queue_to_sqlite_v1'; +const _recentMigrationKey = 'app_state_migrated_recent_to_sqlite_v1'; + +class AppStateDatabase { + static final AppStateDatabase instance = AppStateDatabase._init(); + static Database? _database; + + final Future _prefs = SharedPreferences.getInstance(); + + AppStateDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDb(); + return _database!; + } + + Future _initDb() async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, _dbFileName); + + _log.i('Initializing app state database at: $path'); + + return openDatabase( + path, + version: _dbVersion, + onCreate: _createDb, + onUpgrade: _upgradeDb, + ); + } + + Future _createDb(Database db, int version) async { + _log.i('Creating app state database schema v$version'); + + await db.execute(''' + CREATE TABLE $_queueTable ( + id TEXT PRIMARY KEY, + item_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_${_queueTable}_status ON $_queueTable(status)', + ); + await db.execute( + 'CREATE INDEX idx_${_queueTable}_created ON $_queueTable(created_at ASC)', + ); + + await db.execute(''' + CREATE TABLE $_recentTable ( + unique_key TEXT PRIMARY KEY, + item_json TEXT NOT NULL, + accessed_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_${_recentTable}_accessed ON $_recentTable(accessed_at DESC)', + ); + + await db.execute(''' + CREATE TABLE $_hiddenRecentTable ( + download_id TEXT PRIMARY KEY, + updated_at TEXT NOT NULL + ) + '''); + } + + Future _upgradeDb(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading app state database from v$oldVersion to v$newVersion'); + } + + Future migrateQueueFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_queueMigrationKey) == true) { + return false; + } + + final raw = prefs.getString(_legacyQueueKey); + if (raw == null || raw.isEmpty) { + await prefs.setBool(_queueMigrationKey, true); + return false; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + await prefs.setBool(_queueMigrationKey, true); + return false; + } + + final nowIso = DateTime.now().toIso8601String(); + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final entry in decoded.whereType()) { + final map = Map.from(entry); + final id = map['id'] as String?; + if (id == null || id.isEmpty) continue; + + final status = map['status'] as String? ?? 'queued'; + if (status != 'queued' && status != 'downloading') { + continue; + } + + if (status == 'downloading') { + map['status'] = 'queued'; + map['progress'] = 0.0; + map['speedMBps'] = 0.0; + map['bytesReceived'] = 0; + } + + final createdAt = map['createdAt'] as String? ?? nowIso; + batch.insert(_queueTable, { + 'id': id, + 'item_json': jsonEncode(map), + 'status': 'queued', + 'created_at': createdAt, + 'updated_at': nowIso, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + }); + + await prefs.setBool(_queueMigrationKey, true); + _log.i('Migrated legacy queue data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed queue migration to SQLite: $e', e, stack); + return false; + } + } + + Future migrateRecentAccessFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_recentMigrationKey) == true) { + return false; + } + + final rawRecent = prefs.getString(_legacyRecentAccessKey); + final hiddenIds = prefs.getStringList(_legacyHiddenDownloadsKey); + if ((rawRecent == null || rawRecent.isEmpty) && + (hiddenIds == null || hiddenIds.isEmpty)) { + await prefs.setBool(_recentMigrationKey, true); + return false; + } + + try { + final nowIso = DateTime.now().toIso8601String(); + final db = await database; + await db.transaction((txn) async { + if (rawRecent != null && rawRecent.isNotEmpty) { + final decoded = jsonDecode(rawRecent); + if (decoded is List) { + final batch = txn.batch(); + for (final entry in decoded.whereType()) { + final map = Map.from(entry); + final type = map['type'] as String?; + final id = map['id'] as String?; + final providerId = map['providerId'] as String?; + if (type == null || id == null || type.isEmpty || id.isEmpty) { + continue; + } + final uniqueKey = '$type:${providerId ?? 'default'}:$id'; + final accessedAt = map['accessedAt'] as String? ?? nowIso; + batch.insert(_recentTable, { + 'unique_key': uniqueKey, + 'item_json': jsonEncode(map), + 'accessed_at': accessedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + } + + if (hiddenIds != null && hiddenIds.isNotEmpty) { + final batch = txn.batch(); + for (final id in hiddenIds) { + if (id.isEmpty) continue; + batch.insert(_hiddenRecentTable, { + 'download_id': id, + 'updated_at': nowIso, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + }); + + await prefs.setBool(_recentMigrationKey, true); + _log.i('Migrated legacy recent-access data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed recent-access migration to SQLite: $e', e, stack); + return false; + } + } + + Future>> getPendingDownloadQueueRows() async { + final db = await database; + return db.query( + _queueTable, + where: 'status = ? OR status = ?', + whereArgs: ['queued', 'downloading'], + orderBy: 'created_at ASC, rowid ASC', + ); + } + + Future replacePendingDownloadQueueRows( + List> rows, + ) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete(_queueTable); + if (rows.isEmpty) return; + + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + _queueTable, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + Future>> getRecentAccessRows({int? limit}) async { + final db = await database; + return db.query( + _recentTable, + orderBy: 'accessed_at DESC, rowid DESC', + limit: limit, + ); + } + + Future upsertRecentAccessRow({ + required String uniqueKey, + required String itemJson, + required String accessedAt, + }) async { + final db = await database; + await db.insert(_recentTable, { + 'unique_key': uniqueKey, + 'item_json': itemJson, + 'accessed_at': accessedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteRecentAccessRow(String uniqueKey) async { + final db = await database; + await db.delete( + _recentTable, + where: 'unique_key = ?', + whereArgs: [uniqueKey], + ); + } + + Future clearRecentAccessRows() async { + final db = await database; + await db.delete(_recentTable); + } + + Future> getHiddenRecentDownloadIds() async { + final db = await database; + final rows = await db.query(_hiddenRecentTable, columns: ['download_id']); + return rows + .map((row) => row['download_id'] as String?) + .whereType() + .toSet(); + } + + Future addHiddenRecentDownloadId(String downloadId) async { + final id = downloadId.trim(); + if (id.isEmpty) return; + final db = await database; + await db.insert(_hiddenRecentTable, { + 'download_id': id, + 'updated_at': DateTime.now().toIso8601String(), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future clearHiddenRecentDownloadIds() async { + final db = await database; + await db.delete(_hiddenRecentTable); + } +} diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart new file mode 100644 index 00000000..ae1b3fdf --- /dev/null +++ b/lib/services/library_collections_database.dart @@ -0,0 +1,411 @@ +import 'dart:convert'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('LibraryCollectionsDb'); + +const _dbFileName = 'library_collections.db'; +const _dbVersion = 1; + +const _tableWishlist = 'wishlist_tracks'; +const _tableLoved = 'loved_tracks'; +const _tablePlaylists = 'playlists'; +const _tablePlaylistTracks = 'playlist_tracks'; + +const _legacyCollectionsStorageKey = 'library_collections_v1'; +const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1'; + +class LibraryCollectionsSnapshot { + final List> wishlistRows; + final List> lovedRows; + final List> playlistRows; + final List> playlistTrackRows; + + const LibraryCollectionsSnapshot({ + required this.wishlistRows, + required this.lovedRows, + required this.playlistRows, + required this.playlistTrackRows, + }); +} + +class LibraryCollectionsDatabase { + static final LibraryCollectionsDatabase instance = + LibraryCollectionsDatabase._init(); + static Database? _database; + + final Future _prefs = SharedPreferences.getInstance(); + + LibraryCollectionsDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDb(); + return _database!; + } + + Future _initDb() async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, _dbFileName); + + _log.i('Initializing collections database at: $path'); + + return openDatabase( + path, + version: _dbVersion, + onConfigure: (db) async { + await db.execute('PRAGMA foreign_keys = ON'); + }, + onCreate: _createDb, + onUpgrade: _upgradeDb, + ); + } + + Future _createDb(Database db, int version) async { + _log.i('Creating collections database schema v$version'); + + await db.execute(''' + CREATE TABLE $_tableWishlist ( + track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tableLoved ( + track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tablePlaylists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + cover_image_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tablePlaylistTracks ( + playlist_id TEXT NOT NULL, + track_key TEXT NOT NULL, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (playlist_id, track_key), + FOREIGN KEY (playlist_id) REFERENCES $_tablePlaylists(id) ON DELETE CASCADE + ) + '''); + + await db.execute( + 'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tableLoved}_added_at ON $_tableLoved(added_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylists}_created_at ON $_tablePlaylists(created_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylistTracks}_playlist_id ON $_tablePlaylistTracks(playlist_id)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylistTracks}_added_at ON $_tablePlaylistTracks(added_at DESC)', + ); + } + + Future _upgradeDb(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading collections database from v$oldVersion to v$newVersion'); + } + + Future migrateFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_migrationDoneKey) == true) { + return false; + } + + final raw = prefs.getString(_legacyCollectionsStorageKey); + if (raw == null || raw.isEmpty) { + await prefs.setBool(_migrationDoneKey, true); + return false; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + await prefs.setBool(_migrationDoneKey, true); + return false; + } + + final root = Map.from(decoded); + final wishlistRaw = (root['wishlist'] as List?) ?? const []; + final lovedRaw = (root['loved'] as List?) ?? const []; + final playlistsRaw = (root['playlists'] as List?) ?? const []; + final nowIso = DateTime.now().toIso8601String(); + + final db = await database; + await db.transaction((txn) async { + for (final entry in wishlistRaw.whereType()) { + final map = Map.from(entry); + final trackKey = map['key'] as String?; + final track = map['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (map['addedAt'] as String?) ?? nowIso; + await txn.insert(_tableWishlist, { + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + for (final entry in lovedRaw.whereType()) { + final map = Map.from(entry); + final trackKey = map['key'] as String?; + final track = map['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (map['addedAt'] as String?) ?? nowIso; + await txn.insert(_tableLoved, { + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + for (final playlistEntry in playlistsRaw.whereType()) { + final playlist = Map.from(playlistEntry); + final playlistId = playlist['id'] as String?; + if (playlistId == null || playlistId.isEmpty) continue; + + final createdAt = (playlist['createdAt'] as String?) ?? nowIso; + final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt; + await txn.insert(_tablePlaylists, { + 'id': playlistId, + 'name': (playlist['name'] as String?) ?? '', + 'cover_image_path': playlist['coverImagePath'] as String?, + 'created_at': createdAt, + 'updated_at': updatedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + + final tracksRaw = (playlist['tracks'] as List?) ?? const []; + for (final trackEntry in tracksRaw.whereType()) { + final trackMap = Map.from(trackEntry); + final trackKey = trackMap['key'] as String?; + final track = trackMap['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; + await txn.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + }); + + await prefs.setBool(_migrationDoneKey, true); + _log.i('Migrated legacy collections data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed migrating collections to SQLite: $e', e, stack); + return false; + } + } + + Future loadSnapshot() async { + final db = await database; + final wishlistRows = await db.query( + _tableWishlist, + orderBy: 'added_at DESC, rowid DESC', + ); + final lovedRows = await db.query( + _tableLoved, + orderBy: 'added_at DESC, rowid DESC', + ); + final playlistRows = await db.query( + _tablePlaylists, + orderBy: 'created_at DESC, rowid DESC', + ); + final playlistTrackRows = await db.query( + _tablePlaylistTracks, + orderBy: 'playlist_id ASC, added_at DESC, rowid DESC', + ); + + return LibraryCollectionsSnapshot( + wishlistRows: wishlistRows, + lovedRows: lovedRows, + playlistRows: playlistRows, + playlistTrackRows: playlistTrackRows, + ); + } + + Future upsertWishlistEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableWishlist, { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteWishlistEntry(String trackKey) async { + final db = await database; + await db.delete( + _tableWishlist, + where: 'track_key = ?', + whereArgs: [trackKey], + ); + } + + Future upsertLovedEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableLoved, { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteLovedEntry(String trackKey) async { + final db = await database; + await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]); + } + + Future upsertPlaylist({ + required String id, + required String name, + required String createdAt, + required String updatedAt, + String? coverImagePath, + }) async { + final db = await database; + await db.insert(_tablePlaylists, { + 'id': id, + 'name': name, + 'cover_image_path': coverImagePath, + 'created_at': createdAt, + 'updated_at': updatedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future renamePlaylist({ + required String playlistId, + required String name, + required String updatedAt, + }) async { + final db = await database; + await db.update( + _tablePlaylists, + {'name': name, 'updated_at': updatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + } + + Future updatePlaylistCover({ + required String playlistId, + required String updatedAt, + String? coverImagePath, + }) async { + final db = await database; + await db.update( + _tablePlaylists, + {'cover_image_path': coverImagePath, 'updated_at': updatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + } + + Future deletePlaylist(String playlistId) async { + final db = await database; + await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]); + } + + Future upsertPlaylistTrack({ + required String playlistId, + required String trackKey, + required String trackJson, + required String addedAt, + required String playlistUpdatedAt, + }) async { + final db = await database; + await db.transaction((txn) async { + await txn.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } + + Future upsertPlaylistTracksBatch({ + required String playlistId, + required String playlistUpdatedAt, + required List> tracks, + }) async { + if (tracks.isEmpty) return; + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final track in tracks) { + batch.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': track['track_key'], + 'track_json': track['track_json'], + 'added_at': track['added_at'], + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + batch.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + await batch.commit(noResult: true); + }); + } + + Future deletePlaylistTrack({ + required String playlistId, + required String trackKey, + required String playlistUpdatedAt, + }) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete( + _tablePlaylistTracks, + where: 'playlist_id = ? AND track_key = ?', + whereArgs: [playlistId, trackKey], + ); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 7eb7ee42..dc9b5256 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -18,7 +18,9 @@ Future showAddTrackToPlaylistSheet( context: context, showDragHandle: true, builder: (sheetContext) { - final playlists = ref.watch(libraryCollectionsProvider).playlists; + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, From ab72a105782b29607b19273b64e85adf8705c857 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 18:27:14 +0700 Subject: [PATCH 14/38] feat: add multi-select to library folders, batch playlist picker, and Go backend FD safety - Add multi-select support to library_tracks_folder_screen (wishlist, loved, playlist) with long-press to enter selection mode, animated bottom bar with batch remove/download/add-to-playlist actions, and PopScope exit handling - Create batch showAddTracksToPlaylistSheet in playlist_picker_sheet with playlist thumbnail widget and cover image support - Add playlist grid selection tint overlay in queue_tab - Optimize collection lookups with pre-built _allPlaylistTrackKeys index and isTrackInAnyPlaylist/hasPlaylistTracks accessors - Eagerly initialize localLibraryProvider and libraryCollectionsProvider - Enable SQLite WAL mode and PRAGMA synchronous=NORMAL across all databases - Go backend: duplicate SAF output FDs before provider attempts to prevent fdsan abort on fallback retries; close detached FDs after download completes - Go backend: rewrite compatibilityTransport to try HTTPS first and only fallback to HTTP on transport-level failures, preventing redirect loops - Go backend: enforce HTTPS-only for extension sandbox HTTP clients --- go_backend/exports.go | 4 + go_backend/extension_runtime.go | 11 +- go_backend/httputil.go | 62 +- go_backend/output_fd.go | 44 +- go_backend/output_fd_unix.go | 35 ++ go_backend/output_fd_windows.go | 29 + lib/main.dart | 4 + .../library_collections_provider.dart | 23 +- lib/screens/library_tracks_folder_screen.dart | 560 +++++++++++++++--- lib/screens/queue_tab.dart | 44 +- lib/services/app_state_database.dart | 4 + lib/services/history_database.dart | 4 + .../library_collections_database.dart | 2 + lib/services/library_database.dart | 118 ++-- lib/widgets/playlist_picker_sheet.dart | 254 +++++++- 15 files changed, 1042 insertions(+), 156 deletions(-) create mode 100644 go_backend/output_fd_unix.go create mode 100644 go_backend/output_fd_windows.go diff --git a/go_backend/exports.go b/go_backend/exports.go index dbae733e..1315280d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -383,6 +383,7 @@ func DownloadTrack(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -565,6 +566,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -1531,6 +1533,7 @@ func DownloadFromYouTube(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -2248,6 +2251,7 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return "", fmt.Errorf("invalid request: %w", err) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 2e33d227..ad2610c0 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -102,8 +102,15 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { vm: ext.VM, } - client := NewHTTPClientWithTimeout(30 * time.Second) - client.Jar = jar + // Extension sandbox enforces HTTPS-only domains. Do not apply global + // allow_http scheme downgrade here, because some extension APIs (e.g. + // spotify-web) will redirect http -> https and can end up in 301 loops. + // We still reuse sharedTransport so insecure TLS compatibility mode remains effective. + client := &http.Client{ + Transport: sharedTransport, + Timeout: 30 * time.Second, + Jar: jar, + } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if req.URL.Scheme != "https" { GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 991904d0..08137fa8 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -170,34 +170,71 @@ func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper { } func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) { - reqCompat := applyCompatibilityToRequest(req) - return t.base.RoundTrip(reqCompat) -} - -func applyCompatibilityToRequest(req *http.Request) *http.Request { if req == nil || req.URL == nil { - return req + return t.base.RoundTrip(req) } opts := GetNetworkCompatibilityOptions() if !opts.AllowHTTP || req.URL.Scheme != "https" { - return req + return t.base.RoundTrip(req) } + // Compatibility mode should prefer HTTPS and only fallback to HTTP on + // transport-level failures. Forcing HTTP unconditionally can trigger + // redirect loops (http -> https) on providers that enforce HTTPS. + resp, err := t.base.RoundTrip(req) + if err == nil { + return resp, nil + } + + if !canFallbackToHTTP(req) { + return nil, err + } + + fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http") + if cloneErr != nil { + return nil, err + } + + GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err) + return t.base.RoundTrip(fallbackReq) +} + +func canFallbackToHTTP(req *http.Request) bool { + if req == nil { + return false + } + + switch strings.ToUpper(req.Method) { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete: + return true + default: + return req.GetBody != nil + } +} + +func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) { reqCopy := req.Clone(req.Context()) + if req.Body != nil && req.GetBody != nil { + bodyCopy, err := req.GetBody() + if err != nil { + return nil, err + } + reqCopy.Body = bodyCopy + } + urlCopy := *req.URL - urlCopy.Scheme = "http" + urlCopy.Scheme = scheme reqCopy.URL = &urlCopy - return reqCopy + return reqCopy, nil } // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - reqToSend := applyCompatibilityToRequest(req) - resp, err := client.Do(reqToSend) + resp, err := client.Do(req) if err != nil { - CheckAndLogISPBlocking(err, reqToSend.URL.String(), "HTTP") + CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") } return resp, err } @@ -226,7 +263,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf for attempt := 0; attempt <= config.MaxRetries; attempt++ { reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) - reqCopy = applyCompatibilityToRequest(reqCopy) resp, err := client.Do(reqCopy) if err != nil { diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index fed09bd5..d5519dce 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -12,7 +12,19 @@ func isFDOutput(outputFD int) bool { func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { if isFDOutput(outputFD) { - return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil + // Never hand the original detached FD directly to a provider attempt. + // Fallback chains may retry with another provider after a failure. + // If the first attempt closes the original FD, its numeric ID can be + // reused by unrelated resources and a later close may trigger fdsan abort. + dupFD, err := dupOutputFD(outputFD) + if err != nil { + return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err) + } + if err := prepareDupFDForWrite(dupFD, outputFD); err != nil { + _ = closeFD(dupFD) + return nil, err + } + return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil } path := strings.TrimSpace(outputPath) @@ -32,6 +44,36 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { return os.Create(outputPath) } +func prepareDupFDForWrite(dupFD, originalFD int) error { + // Best-effort reset so retries start writing from byte 0. + if err := truncateFD(dupFD); err != nil { + if isBestEffortTruncateError(err) { + GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err) + } else { + return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err) + } + } + if err := seekFDStart(dupFD); err != nil { + GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err) + } + return nil +} + +func closeOwnedOutputFD(outputFD int) { + if !isFDOutput(outputFD) { + return + } + + if err := closeFD(outputFD); err != nil { + if !isBadFD(err) { + GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err) + } + return + } + + GoLog("[OutputFD] closed detached fd %d\n", outputFD) +} + func cleanupOutputOnError(outputPath string, outputFD int) { if isFDOutput(outputFD) { return diff --git a/go_backend/output_fd_unix.go b/go_backend/output_fd_unix.go new file mode 100644 index 00000000..a9eb76aa --- /dev/null +++ b/go_backend/output_fd_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package gobackend + +import "syscall" + +func dupOutputFD(fd int) (int, error) { + return syscall.Dup(fd) +} + +func truncateFD(fd int) error { + return syscall.Ftruncate(fd, 0) +} + +func seekFDStart(fd int) error { + _, err := syscall.Seek(fd, 0, 0) + return err +} + +func closeFD(fd int) error { + return syscall.Close(fd) +} + +func isBestEffortTruncateError(err error) bool { + switch err { + case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS: + return true + default: + return false + } +} + +func isBadFD(err error) bool { + return err == syscall.EBADF +} diff --git a/go_backend/output_fd_windows.go b/go_backend/output_fd_windows.go new file mode 100644 index 00000000..a0cedd95 --- /dev/null +++ b/go_backend/output_fd_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package gobackend + +func dupOutputFD(fd int) (int, error) { + // Windows build is primarily for local tooling/tests. + // Android runtime uses the !windows implementation. + return fd, nil +} + +func truncateFD(fd int) error { + return nil +} + +func seekFDStart(fd int) error { + return nil +} + +func closeFD(fd int) error { + return nil +} + +func isBestEffortTruncateError(err error) bool { + return true +} + +func isBadFD(err error) bool { + return false +} diff --git a/lib/main.dart b/lib/main.dart index 1fc79299..6963a9ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -93,6 +95,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { _initializeAppServices(); _initializeExtensions(); ref.read(downloadHistoryProvider); + ref.read(localLibraryProvider); + ref.read(libraryCollectionsProvider); } Future _initializeAppServices() async { diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 485e3482..a7a3e373 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -135,6 +135,7 @@ class LibraryCollectionsState { final Set _wishlistKeys; final Set _lovedKeys; final Map _playlistsById; + final Set _allPlaylistTrackKeys; LibraryCollectionsState({ this.wishlist = const [], @@ -144,6 +145,7 @@ class LibraryCollectionsState { Set? wishlistKeys, Set? lovedKeys, Map? playlistsById, + Set? allPlaylistTrackKeys, }) : _wishlistKeys = wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), @@ -151,7 +153,9 @@ class LibraryCollectionsState { playlistsById ?? Map.fromEntries( playlists.map((playlist) => MapEntry(playlist.id, playlist)), - ); + ), + _allPlaylistTrackKeys = + allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists); int get wishlistCount => wishlist.length; int get lovedCount => loved.length; @@ -185,6 +189,12 @@ class LibraryCollectionsState { return playlist.containsTrackKey(trackKey); } + bool isTrackInAnyPlaylist(String trackKey) { + return _allPlaylistTrackKeys.contains(trackKey); + } + + bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty; + LibraryCollectionsState copyWith({ List? wishlist, List? loved, @@ -206,6 +216,7 @@ class LibraryCollectionsState { wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, lovedKeys: keepLovedIndex ? _lovedKeys : null, playlistsById: keepPlaylistIndex ? _playlistsById : null, + allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, ); } @@ -245,6 +256,16 @@ class LibraryCollectionsState { } } +Set _buildPlaylistTrackKeys(List playlists) { + final keys = {}; + for (final playlist in playlists) { + for (final entry in playlist.tracks) { + keys.add(entry.key); + } + } + return keys; +} + class PlaylistAddBatchResult { final int addedCount; final int alreadyInPlaylistCount; diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 8968c851..00a0665f 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -33,6 +34,10 @@ class _LibraryTracksFolderScreenState bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + // ── Multi-select state ── + bool _isSelectionMode = false; + final Set _selectedKeys = {}; + @override void initState() { super.initState(); @@ -101,6 +106,112 @@ class _LibraryTracksFolderScreenState return url; } + // ── Selection helpers ── + + void _enterSelectionMode(String key) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedKeys.add(key); + }); + } + + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedKeys.clear(); + }); + } + + void _toggleSelection(String key) { + setState(() { + if (_selectedKeys.contains(key)) { + _selectedKeys.remove(key); + if (_selectedKeys.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedKeys.add(key); + } + }); + } + + void _selectAll(List entries) { + setState(() { + _selectedKeys.addAll(entries.map((e) => e.key)); + }); + } + + // ── Batch actions ── + + Future _removeSelected(List entries) async { + final keysToRemove = _selectedKeys.toSet(); + if (keysToRemove.isEmpty) return; + + final count = keysToRemove.length; + final notifier = ref.read(libraryCollectionsProvider.notifier); + + for (final key in keysToRemove) { + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeFromWishlist(key); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeFromLoved(key); + break; + case LibraryTracksFolderMode.playlist: + if (widget.playlistId != null) { + await notifier.removeTrackFromPlaylist(widget.playlistId!, key); + } + break; + } + } + + _exitSelectionMode(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionSelected(count), + ), + ), + ); + } + + void _downloadSelected(List entries) { + final settings = ref.read(settingsProvider); + final queueNotifier = ref.read(downloadQueueProvider.notifier); + var count = 0; + + for (final entry in entries) { + if (!_selectedKeys.contains(entry.key)) continue; + queueNotifier.addToQueue(entry.track, settings.defaultService); + count++; + } + + _exitSelectionMode(); + + if (!mounted || count == 0) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionSelected(count), + ), + ), + ); + } + + void _addSelectedToPlaylist(List entries) { + final selectedTracks = entries + .where((e) => _selectedKeys.contains(e.key)) + .map((e) => e.track) + .toList(growable: false); + if (selectedTracks.isEmpty) return; + + showAddTracksToPlaylistSheet(context, ref, selectedTracks); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -133,6 +244,17 @@ class _LibraryTracksFolderScreenState break; } + // Stale selection cleanup + if (_isSelectionMode) { + final validKeys = entries.map((e) => e.key).toSet(); + _selectedKeys.removeWhere((key) => !validKeys.contains(key)); + if (_selectedKeys.isEmpty && _isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isSelectionMode = false); + }); + } + } + final title = switch (widget.mode) { LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, LibraryTracksFolderMode.loved => context.l10n.collectionLoved, @@ -157,42 +279,247 @@ class _LibraryTracksFolderScreenState context.l10n.collectionPlaylistEmptySubtitle, }; - return Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - _buildAppBar(context, colorScheme, title, entries, playlist), - if (entries.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _EmptyFolderState( - title: emptyTitle, - subtitle: emptySubtitle, - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final entry = entries[index]; - return KeyedSubtree( - key: ValueKey(entry.key), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _CollectionTrackTile( - entry: entry, - mode: widget.mode, - playlistId: widget.playlistId, - ), - if (index < entries.length - 1) const Divider(height: 1), - ], + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar(context, colorScheme, title, entries, playlist), + if (entries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyFolderState( + title: emptyTitle, + subtitle: emptySubtitle, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = entries[index]; + final isSelected = _selectedKeys.contains(entry.key); + return KeyedSubtree( + key: ValueKey(entry.key), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), + ), + if (index < entries.length - 1) + const Divider(height: 1), + ], + ), + ); + }, childCount: entries.length), ), - ); - }, childCount: entries.length), + SliverToBoxAdapter( + child: SizedBox(height: _isSelectionMode ? 200 : 32), + ), + ], ), - const SliverToBoxAdapter(child: SizedBox(height: 32)), + + // Selection bottom bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(280 + bottomPadding), + child: _buildSelectionBottomBar( + context, + colorScheme, + entries, + bottomPadding, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List entries, + double bottomPadding, + ) { + final selectedCount = _selectedKeys.length; + final allSelected = selectedCount == entries.length && entries.isNotEmpty; + final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), ], ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header: [X close] [count] [Select All / Deselect] + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.selectionSelected(selectedCount), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? context.l10n.selectionAllSelected + : context.l10n.selectionSelectToDelete, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(entries); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Action buttons row + Row( + children: [ + if (isWishlist) + Expanded( + child: _SelectionActionButton( + icon: Icons.download, + label: + '${context.l10n.settingsDownload} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _downloadSelected(entries) + : null, + colorScheme: colorScheme, + ), + ), + if (isWishlist) const SizedBox(width: 8), + Expanded( + child: _SelectionActionButton( + icon: Icons.playlist_add, + label: + '${context.l10n.collectionAddToPlaylist} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _addSelectedToPlaylist(entries) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Remove button (full width, red) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _removeSelected(entries) + : null, + icon: const Icon(Icons.remove_circle_outline), + label: Text( + selectedCount > 0 + ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' + : widget.mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), ); } @@ -250,7 +577,9 @@ class _LibraryTracksFolderScreenState duration: const Duration(milliseconds: 200), opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( - title, + _isSelectionMode + ? context.l10n.selectionSelected(_selectedKeys.length) + : title, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -261,7 +590,7 @@ class _LibraryTracksFolderScreenState ), ), actions: [ - if (isPlaylistMode) + if (isPlaylistMode && !_isSelectionMode) IconButton( icon: Container( padding: const EdgeInsets.all(8), @@ -422,9 +751,14 @@ class _LibraryTracksFolderScreenState color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back, color: Colors.white), + child: Icon( + _isSelectionMode ? Icons.close : Icons.arrow_back, + color: Colors.white, + ), ), - onPressed: () => Navigator.pop(context), + onPressed: _isSelectionMode + ? _exitSelectionMode + : () => Navigator.pop(context), ), ); } @@ -511,51 +845,110 @@ class _CollectionTrackTile extends ConsumerWidget { final CollectionTrackEntry entry; final LibraryTracksFolderMode mode; final String? playlistId; + final bool isSelectionMode; + final bool isSelected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; const _CollectionTrackTile({ required this.entry, required this.mode, required this.playlistId, + this.isSelectionMode = false, + this.isSelected = false, + this.onTap, + this.onLongPress, }); @override Widget build(BuildContext context, WidgetRef ref) { final track = entry.track; + final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && track.coverUrl!.isNotEmpty - ? _buildTrackCover(context, track.coverUrl!, 52) - : Container( - width: 52, - height: 52, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelectionMode) ...[ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) + : null, ), + const SizedBox(width: 12), + ], + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: track.coverUrl != null && track.coverUrl!.isNotEmpty + ? _buildTrackCover(context, track.coverUrl!, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), - ), - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: IconButton( - icon: Icon( - Icons.more_vert, - color: Theme.of(context).colorScheme.onSurfaceVariant, - size: 20, + ], + ), + title: + Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: isSelectionMode + ? null + : IconButton( + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + ), + onTap: isSelectionMode + ? onTap + : mode == LibraryTracksFolderMode.wishlist + ? () => _downloadTrack(context, ref) + : () => _navigateToMetadata(context, ref), + onLongPress: isSelectionMode ? onTap : onLongPress, ), - onPressed: () => _showTrackOptionsSheet(context, ref), ), - onTap: mode == LibraryTracksFolderMode.wishlist - ? () => _downloadTrack(context, ref) - : () => _navigateToMetadata(context, ref), - onLongPress: () => _showTrackOptionsSheet(context, ref), ); } @@ -831,6 +1224,41 @@ class _CollectionOptionTile extends StatelessWidget { } } +class _SelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _SelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + style: FilledButton.styleFrom( + backgroundColor: onPressed != null + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + foregroundColor: onPressed != null + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ); + } +} + class _EmptyFolderState extends StatelessWidget { final String title; final String subtitle; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 66efe2b6..6a5390cd 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2643,16 +2643,11 @@ class _QueueTabState extends ConsumerState { // Apply advanced filters to match what's displayed final filtered = _applyAdvancedFilters(unifiedItems); - // Exclude tracks already in a playlist - final playlistTrackKeys = {}; - for (final playlist in collectionState.playlists) { - for (final entry in playlist.tracks) { - playlistTrackKeys.add(entry.key); - } - } - if (playlistTrackKeys.isEmpty) return filtered; + if (!collectionState.hasPlaylistTracks) return filtered; return filtered - .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .where( + (item) => !collectionState.isTrackInAnyPlaylist(item.collectionKey), + ) .toList(growable: false); } @@ -2741,17 +2736,13 @@ class _QueueTabState extends ConsumerState { // in the main tracks list. When a track is removed from a playlist (or // the playlist is deleted) it will automatically reappear here because it // will no longer be in the set. - final playlistTrackKeys = {}; - for (final playlist in collectionState.playlists) { - for (final entry in playlist.tracks) { - playlistTrackKeys.add(entry.key); - } - } - - final filteredUnifiedItems = playlistTrackKeys.isEmpty + final filteredUnifiedItems = !collectionState.hasPlaylistTracks ? filtered : filtered - .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .where( + (item) => + !collectionState.isTrackInAnyPlaylist(item.collectionKey), + ) .toList(growable: false); return _FilterContentData( @@ -3026,6 +3017,23 @@ class _QueueTabState extends ConsumerState { ? () => _togglePlaylistSelection(playlist.id) : () => _enterPlaylistSelectionMode(playlist.id), ), + if (_isPlaylistSelectionMode) + Positioned( + left: 0, + top: 0, + right: 0, + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), if (_isPlaylistSelectionMode) Positioned( top: 4, diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart index e5ca600c..92ca49f8 100644 --- a/lib/services/app_state_database.dart +++ b/lib/services/app_state_database.dart @@ -45,6 +45,10 @@ class AppStateDatabase { return openDatabase( path, version: _dbVersion, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDb, onUpgrade: _upgradeDb, ); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index b20dd7bc..5a3c1739 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -35,6 +35,10 @@ class HistoryDatabase { return await openDatabase( path, version: 3, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDB, onUpgrade: _upgradeDB, ); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index ae1b3fdf..65577cce 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -59,6 +59,8 @@ class LibraryCollectionsDatabase { version: _dbVersion, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); }, onCreate: _createDb, onUpgrade: _upgradeDb, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index b2243e2b..c92fb7a3 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -95,39 +95,45 @@ class LocalLibraryItem { ); /// Create a unique key for matching tracks - String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; - String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; + String get matchKey => + '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; + String get albumKey => + '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; } class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); static Database? _database; - + LibraryDatabase._init(); - + Future get database async { if (_database != null) return _database!; _database = await _initDB('local_library.db'); return _database!; } - + Future _initDB(String fileName) async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, fileName); - + _log.i('Initializing library database at: $path'); - + return await openDatabase( path, version: 4, // Bumped version for bitrate column + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDB, onUpgrade: _upgradeDB, ); } - + Future _createDB(Database db, int version) async { _log.i('Creating library database schema v$version'); - + await db.execute(''' CREATE TABLE library ( id TEXT PRIMARY KEY, @@ -151,37 +157,43 @@ class LibraryDatabase { format TEXT ) '''); - + await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)'); - await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)'); - await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)'); - await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)'); - + await db.execute( + 'CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)', + ); + await db.execute( + 'CREATE INDEX idx_library_album ON library(album_name, album_artist)', + ); + await db.execute( + 'CREATE INDEX idx_library_file_path ON library(file_path)', + ); + _log.i('Library database schema created with indexes'); } - + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading library database from v$oldVersion to v$newVersion'); - + if (oldVersion < 2) { // Add cover_path column await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); _log.i('Added cover_path column'); } - + if (oldVersion < 3) { // Add file_mod_time column for incremental scanning await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); _log.i('Added file_mod_time column for incremental scanning'); } - + if (oldVersion < 4) { // Add bitrate column for lossy format quality info await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER'); _log.i('Added bitrate column for lossy format quality'); } } - + Map _jsonToDbRow(Map json) { return { 'id': json['id'], @@ -205,7 +217,7 @@ class LibraryDatabase { 'format': json['format'], }; } - + Map _dbRowToJson(Map row) { return { 'id': row['id'], @@ -229,9 +241,9 @@ class LibraryDatabase { 'format': row['format'], }; } - + // CRUD Operations - + Future upsert(Map json) async { final db = await database; await db.insert( @@ -240,12 +252,12 @@ class LibraryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; final batch = db.batch(); - + for (final json in items) { batch.insert( 'library', @@ -253,11 +265,11 @@ class LibraryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + await batch.commit(noResult: true); _log.i('Batch inserted ${items.length} items'); } - + Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( @@ -268,7 +280,7 @@ class LibraryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -280,7 +292,7 @@ class LibraryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + Future?> getByIsrc(String isrc) async { final db = await database; final rows = await db.query( @@ -292,7 +304,7 @@ class LibraryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + Future existsByIsrc(String isrc) async { final db = await database; final result = await db.rawQuery( @@ -301,7 +313,7 @@ class LibraryDatabase { ); return result.isNotEmpty; } - + Future>> findByTrackAndArtist( String trackName, String artistName, @@ -314,7 +326,7 @@ class LibraryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + Future?> findExisting({ String? isrc, String? trackName, @@ -325,42 +337,42 @@ class LibraryDatabase { final byIsrc = await getByIsrc(isrc); if (byIsrc != null) return byIsrc; } - + // Then try name matching if (trackName != null && artistName != null) { final matches = await findByTrackAndArtist(trackName, artistName); if (matches.isNotEmpty) return matches.first; } - + return null; } - + Future> getAllIsrcs() async { final db = await database; final rows = await db.rawQuery( - 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""' + 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""', ); return rows.map((r) => r['isrc'] as String).toSet(); } - + Future> getAllTrackKeys() async { final db = await database; final rows = await db.rawQuery( - 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library' + 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library', ); return rows.map((r) => r['match_key'] as String).toSet(); } - + Future deleteByPath(String filePath) async { final db = await database; await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); } - + Future delete(String id) async { final db = await database; await db.delete('library', where: 'id = ?', whereArgs: [id]); } - + Future cleanupMissingFiles() async { final db = await database; final rows = await db.query('library', columns: ['id', 'file_path']); @@ -409,44 +421,48 @@ class LibraryDatabase { } return removed; } - + Future clearAll() async { final db = await database; await db.delete('library'); _log.i('Cleared all library data'); } - + Future getCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) as count FROM library'); return Sqflite.firstIntValue(result) ?? 0; } - - Future>> search(String query, {int limit = 50}) async { + + Future>> search( + String query, { + int limit = 50, + }) async { final db = await database; final searchQuery = '%${query.toLowerCase()}%'; final rows = await db.query( 'library', - where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', + where: + 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', whereArgs: [searchQuery, searchQuery, searchQuery], orderBy: 'track_name', limit: limit, ); return rows.map(_dbRowToJson).toList(); } - + Future close() async { final db = await database; await db.close(); _database = null; } - + /// Get all file paths with their modification times for incremental scanning /// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds) Future> getFileModTimes() async { final db = await database; final rows = await db.rawQuery( - 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library' + 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', ); final result = {}; for (final row in rows) { @@ -456,7 +472,7 @@ class LibraryDatabase { } return result; } - + /// Update file_mod_time for existing rows using file_path as key. Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; @@ -472,14 +488,14 @@ class LibraryDatabase { } await batch.commit(noResult: true); } - + /// Get all file paths in the library (for detecting deleted files) Future> getAllFilePaths() async { final db = await database; final rows = await db.rawQuery('SELECT file_path FROM library'); return rows.map((r) => r['file_path'] as String).toSet(); } - + /// Delete multiple items by their file paths Future deleteByPaths(List filePaths) async { if (filePaths.isEmpty) return 0; diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index dc9b5256..525d3b28 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -1,8 +1,12 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; Future showAddTrackToPlaylistSheet( BuildContext context, @@ -79,10 +83,9 @@ Future showAddTrackToPlaylistSheet( final playlist = playlists[index]; final alreadyInPlaylist = playlist.containsTrack(track); return ListTile( - leading: Icon( - alreadyInPlaylist - ? Icons.playlist_add_check - : Icons.queue_music, + leading: _PlaylistPickerThumbnail( + playlist: playlist, + isSelected: alreadyInPlaylist, ), title: Text(playlist.name), subtitle: Text( @@ -137,6 +140,127 @@ Future showAddTrackToPlaylistSheet( } } +/// Batch version: add multiple tracks to a chosen playlist. +Future showAddTracksToPlaylistSheet( + BuildContext context, + WidgetRef ref, + List tracks, +) async { + if (tracks.isEmpty) return; + + final notifier = ref.read(libraryCollectionsProvider.notifier); + + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(sheetContext.l10n.collectionAddToPlaylist), + subtitle: Text( + '${tracks.length} ${tracks.length == 1 ? 'track' : 'tracks'}', + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(sheetContext.l10n.collectionCreatePlaylist), + onTap: () async { + Navigator.of(sheetContext).pop(); + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + final result = await notifier.addTracksToPlaylist( + playlistId, + tracks, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.addedCount > 0 + ? context.l10n.collectionAddedToPlaylist(name.trim()) + : context.l10n.collectionAlreadyInPlaylist( + name.trim(), + ), + ), + ), + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + sheetContext.l10n.collectionNoPlaylistsYet, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return ListTile( + leading: _PlaylistPickerThumbnail( + playlist: playlist, + isSelected: false, + ), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + onTap: () async { + final result = await notifier.addTracksToPlaylist( + playlist.id, + tracks, + ); + if (!context.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.addedCount > 0 + ? context.l10n.collectionAddedToPlaylist( + playlist.name, + ) + : context.l10n.collectionAlreadyInPlaylist( + playlist.name, + ), + ), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); +} + Future _promptPlaylistName(BuildContext context) async { final controller = TextEditingController(); final formKey = GlobalKey(); @@ -187,3 +311,125 @@ Future _promptPlaylistName(BuildContext context) async { return result; } + +class _PlaylistPickerThumbnail extends StatelessWidget { + final UserPlaylistCollection playlist; + final bool isSelected; + + const _PlaylistPickerThumbnail({ + required this.playlist, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + const double size = 48; + final borderRadius = BorderRadius.circular(8); + + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + ClipRRect( + borderRadius: borderRadius, + child: _buildCoverImage(colorScheme, size), + ), + if (isSelected) ...[ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.3), + borderRadius: borderRadius, + ), + ), + ), + Positioned( + right: 2, + top: 2, + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.primary, + width: 1.5, + ), + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 14, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildCoverImage(ColorScheme colorScheme, double size) { + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + String? firstCoverUrl; + for (final entry in playlist.tracks) { + final coverUrl = entry.track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + firstCoverUrl = coverUrl; + break; + } + } + + if (firstCoverUrl != null) { + final isLocalPath = + !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + + if (isLocalPath) { + return Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + return CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => _iconFallback(colorScheme, size), + errorWidget: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + return _iconFallback(colorScheme, size); + } + + Widget _iconFallback(ColorScheme colorScheme, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), + ); + } +} From 882afd938b2b270f8bbcbeefdfd87611e35340ee Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 19:16:55 +0700 Subject: [PATCH 15/38] feat: add SongLink region setting and fix track metadata lookup with name+artist fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configurable SongLink region (userCountry) setting with picker UI - Pass songLinkRegion through download request payload to Go backend - Go backend: thread-safe global SongLink region with per-request override - Fix downloaded track not recognized in collection tap: add findByTrackAndArtist fallback in download history lookup chain (Spotify ID → ISRC → name+artist) - Apply same name+artist fallback to isDownloaded check in track options sheet - Add missing library_database.dart import for LocalLibraryItem --- go_backend/exports.go | 12 + go_backend/songlink.go | 39 ++- lib/models/settings.dart | 5 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 17 ++ lib/providers/settings_provider.dart | 21 ++ lib/screens/library_tracks_folder_screen.dart | 79 +++-- .../settings/download_settings_page.dart | 286 ++++++++++++++++++ lib/services/download_request_payload.dart | 4 + 9 files changed, 446 insertions(+), 19 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index 1315280d..6f2412e4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -177,6 +177,7 @@ type DownloadRequest struct { LyricsMode string `json:"lyrics_mode,omitempty"` UseExtensions bool `json:"use_extensions,omitempty"` UseFallback bool `json:"use_fallback,omitempty"` + SongLinkRegion string `json:"songlink_region,omitempty"` } type DownloadResponse struct { @@ -378,11 +379,19 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { } } +func applySongLinkRegionFromRequest(req *DownloadRequest) { + if req == nil { + return + } + SetSongLinkRegion(req.SongLinkRegion) +} + func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) @@ -566,6 +575,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) @@ -1533,6 +1543,7 @@ func DownloadFromYouTube(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) @@ -2251,6 +2262,7 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return "", fmt.Errorf("invalid request: %w", err) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 62f926f8..43cca7aa 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -34,6 +34,8 @@ type TrackAvailability struct { var ( globalSongLinkClient *SongLinkClient songLinkClientOnce sync.Once + songLinkRegion = "US" + songLinkRegionMu sync.RWMutex ) func NewSongLinkClient() *SongLinkClient { @@ -45,6 +47,33 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } +func normalizeSongLinkRegion(region string) string { + normalized := strings.ToUpper(strings.TrimSpace(region)) + if len(normalized) != 2 { + return "US" + } + for _, ch := range normalized { + if ch < 'A' || ch > 'Z' { + return "US" + } + } + return normalized +} + +func SetSongLinkRegion(region string) { + normalized := normalizeSongLinkRegion(region) + songLinkRegionMu.Lock() + songLinkRegion = normalized + songLinkRegionMu.Unlock() +} + +func GetSongLinkRegion() string { + songLinkRegionMu.RLock() + region := songLinkRegion + songLinkRegionMu.RUnlock() + return region +} + func songLinkBaseURL() string { opts := GetNetworkCompatibilityOptions() if opts.AllowHTTP { @@ -54,6 +83,9 @@ func songLinkBaseURL() string { } func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { + if userCountry == "" { + userCountry = GetSongLinkRegion() + } apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL)) if userCountry != "" { apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) @@ -62,6 +94,9 @@ func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { } func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string { + if userCountry == "" { + userCountry = GetSongLinkRegion() + } apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s", songLinkBaseURL(), url.QueryEscape(platform), @@ -448,7 +483,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin songLinkRateLimiter.WaitForSlot() deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - apiURL := buildSongLinkURLFromTarget(deezerURL, "US") + apiURL := buildSongLinkURLFromTarget(deezerURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -552,7 +587,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit songLinkRateLimiter.WaitForSlot() - apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "US") + apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index ba7b6f09..d499bf01 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -51,6 +51,8 @@ class AppSettings { downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only final bool networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests + final String + songLinkRegion; // SongLink userCountry region code used for platform lookup // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning @@ -115,6 +117,7 @@ class AppSettings { this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', this.networkCompatibilityMode = false, + this.songLinkRegion = 'US', // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', @@ -177,6 +180,7 @@ class AppSettings { bool? autoExportFailedDownloads, String? downloadNetworkMode, bool? networkCompatibilityMode, + String? songLinkRegion, // Local Library bool? localLibraryEnabled, String? localLibraryPath, @@ -241,6 +245,7 @@ class AppSettings { downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, networkCompatibilityMode: networkCompatibilityMode ?? this.networkCompatibilityMode, + songLinkRegion: songLinkRegion ?? this.songLinkRegion, // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 04f4028f..fd02b464 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -54,6 +54,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['networkCompatibilityMode'] as bool? ?? json['songLinkCompatibilityMode'] as bool? ?? false, + songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryShowDuplicates: @@ -117,6 +118,7 @@ Map _$AppSettingsToJson( 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, 'networkCompatibilityMode': instance.networkCompatibilityMode, + 'songLinkRegion': instance.songLinkRegion, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a41f5484..6860ea0f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -232,6 +232,22 @@ class DownloadHistoryState { DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; + DownloadHistoryItem? findByTrackAndArtist( + String trackName, + String artistName, + ) { + final normalizedTrack = trackName.trim().toLowerCase(); + final normalizedArtist = artistName.trim().toLowerCase(); + if (normalizedTrack.isEmpty) return null; + for (final item in items) { + if (item.trackName.trim().toLowerCase() == normalizedTrack && + item.artistName.trim().toLowerCase() == normalizedArtist) { + return item; + } + } + return null; + } + DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); } @@ -3111,6 +3127,7 @@ class DownloadQueueNotifier extends Notifier { safRelativeDir: relativeDir, safFileName: fileName, safOutputExt: outputExt, + songLinkRegion: settings.songLinkRegion, ); return PlatformBridge.downloadByStrategy( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 696d893e..f293d005 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -15,6 +15,7 @@ final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { static const List _youtubeOpusSupportedBitrates = [128, 256]; static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; + static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); @@ -36,6 +37,7 @@ class SettingsNotifier extends Notifier { await _runMigrations(prefs); await _normalizeYouTubeBitratesIfNeeded(); + await _normalizeSongLinkRegionIfNeeded(); } await _loadSpotifyClientSecret(prefs); @@ -165,6 +167,19 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } + String _normalizeSongLinkRegion(String region) { + final normalized = region.trim().toUpperCase(); + if (_isoRegionPattern.hasMatch(normalized)) return normalized; + return 'US'; + } + + Future _normalizeSongLinkRegionIfNeeded() async { + final normalized = _normalizeSongLinkRegion(state.songLinkRegion); + if (normalized == state.songLinkRegion) return; + state = state.copyWith(songLinkRegion: normalized); + await _saveSettings(); + } + Future _loadSpotifyClientSecret(SharedPreferences prefs) async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, @@ -483,6 +498,12 @@ class SettingsNotifier extends Notifier { _syncNetworkCompatibilitySettingsToBackend(); } + void setSongLinkRegion(String region) { + final normalized = _normalizeSongLinkRegion(region); + state = state.copyWith(songLinkRegion: normalized); + _saveSettings(); + } + void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 00a0665f..0167849a 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -8,6 +8,8 @@ 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/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -992,9 +994,12 @@ class _CollectionTrackTile extends ConsumerWidget { void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; - final isDownloaded = ref.read( - downloadHistoryProvider.select((state) => state.isDownloaded(track.id)), - ); + final historyState = ref.read(downloadHistoryProvider); + final isDownloaded = historyState.isDownloaded(track.id) || + (track.isrc != null && + track.isrc!.isNotEmpty && + historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != null; // Wishlist: only show "Add to Playlist" if track is already downloaded final showAddToPlaylist = mode != LibraryTracksFolderMode.wishlist || isDownloaded; @@ -1168,22 +1173,62 @@ class _CollectionTrackTile extends ConsumerWidget { Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { final track = entry.track; - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); + final historyState = ref.read(downloadHistoryProvider); - if (historyItem == null) return; + // 1. Download history by Spotify ID + var historyItem = historyState.getBySpotifyId(track.id); - await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), - ); + // 2. Download history by ISRC + if (historyItem == null && + track.isrc != null && + track.isrc!.isNotEmpty) { + historyItem = historyState.getByIsrc(track.isrc!); + } + + // 3. Download history by track name + artist (handles ID/ISRC mismatch) + historyItem ??= + historyState.findByTrackAndArtist(track.name, track.artistName); + + if (historyItem != null) { + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + return; + } + + // 4. Local library by ISRC + final localState = ref.read(localLibraryProvider); + LocalLibraryItem? localItem; + if (track.isrc != null && track.isrc!.isNotEmpty) { + localItem = localState.getByIsrc(track.isrc!); + } + + // 5. Local library by track name + artist + localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + + if (localItem != null) { + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(localItem: localItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + return; + } + + // 6. Not found anywhere — offer to download + _downloadTrack(context, ref); } } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 6577367b..389629f1 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -24,6 +24,206 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { class _DownloadSettingsPageState extends ConsumerState { static const _builtInServices = ['tidal', 'qobuz', 'amazon']; + static const _songLinkRegions = [ + 'AD', + 'AE', + 'AG', + 'AL', + 'AM', + 'AO', + 'AR', + 'AT', + 'AU', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BN', + 'BO', + 'BR', + 'BS', + 'BT', + 'BW', + 'BZ', + 'CA', + 'CD', + 'CG', + 'CH', + 'CI', + 'CL', + 'CM', + 'CO', + 'CR', + 'CV', + 'CW', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FM', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GH', + 'GM', + 'GN', + 'GQ', + 'GR', + 'GT', + 'GW', + 'GY', + 'HK', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IN', + 'IQ', + 'IS', + 'IT', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KR', + 'KW', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MG', + 'MH', + 'MK', + 'ML', + 'MN', + 'MO', + 'MR', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NE', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PG', + 'PH', + 'PK', + 'PL', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RO', + 'RS', + 'RW', + 'SA', + 'SB', + 'SC', + 'SE', + 'SG', + 'SI', + 'SK', + 'SL', + 'SM', + 'SN', + 'SR', + 'ST', + 'SV', + 'SZ', + 'TD', + 'TG', + 'TH', + 'TJ', + 'TL', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'US', + 'UY', + 'UZ', + 'VC', + 'VE', + 'VN', + 'VU', + 'WS', + 'XK', + 'ZA', + 'ZM', + 'ZW', + ]; + static const _songLinkRegionNames = { + 'US': 'United States', + 'GB': 'United Kingdom', + 'FR': 'France', + 'DE': 'Germany', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'ID': 'Indonesia', + 'BR': 'Brazil', + 'MX': 'Mexico', + 'AU': 'Australia', + 'CA': 'Canada', + 'XK': 'Kosovo', + }; int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; bool _artistFolderFiltersExpanded = false; @@ -536,6 +736,16 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + SettingsItem( + icon: Icons.public, + title: 'SongLink Region', + subtitle: _getSongLinkRegionLabel(settings.songLinkRegion), + onTap: () => _showSongLinkRegionPicker( + context, + ref, + settings.songLinkRegion, + ), + ), SettingsSwitchItem( icon: Icons.security_outlined, title: 'Network compatibility mode', @@ -1225,6 +1435,14 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getSongLinkRegionLabel(String code) { + final normalized = code.trim().toUpperCase(); + final effective = normalized.isEmpty ? 'US' : normalized; + final name = _songLinkRegionNames[effective]; + if (name == null) return effective; + return '$effective - $name'; + } + void _showLyricsModePicker( BuildContext context, WidgetRef ref, @@ -1630,6 +1848,74 @@ class _DownloadSettingsPageState extends ConsumerState { ); } + void _showSongLinkRegionPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + final normalizedCurrent = current.trim().toUpperCase(); + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'SongLink Region', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Used as userCountry for SongLink API lookup.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _songLinkRegions.length, + itemBuilder: (context, index) { + final code = _songLinkRegions[index]; + final isSelected = code == normalizedCurrent; + final displayName = _songLinkRegionNames[code]; + return ListTile( + title: Text(code), + subtitle: displayName != null ? Text(displayName) : null, + trailing: isSelected + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setSongLinkRegion(code); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + void _showFolderOrganizationPicker( BuildContext context, WidgetRef ref, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index bff0a941..afdbfdcf 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -33,6 +33,7 @@ class DownloadRequestPayload { final String safRelativeDir; final String safFileName; final String safOutputExt; + final String songLinkRegion; const DownloadRequestPayload({ this.isrc = '', @@ -69,6 +70,7 @@ class DownloadRequestPayload { this.safRelativeDir = '', this.safFileName = '', this.safOutputExt = '', + this.songLinkRegion = 'US', }); Map toJson() { @@ -107,6 +109,7 @@ class DownloadRequestPayload { 'saf_relative_dir': safRelativeDir, 'saf_file_name': safFileName, 'saf_output_ext': safOutputExt, + 'songlink_region': songLinkRegion, }; } @@ -149,6 +152,7 @@ class DownloadRequestPayload { safRelativeDir: safRelativeDir, safFileName: safFileName, safOutputExt: safOutputExt, + songLinkRegion: songLinkRegion, ); } } From 9460e9faaef2a2a23acea1c69c4ddd830412e172 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 19:26:08 +0700 Subject: [PATCH 16/38] fix: remove dividers and align content padding in playlist track list to match album screen --- lib/screens/library_tracks_folder_screen.dart | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 0167849a..cd9e68c4 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -312,25 +312,18 @@ class _LibraryTracksFolderScreenState final isSelected = _selectedKeys.contains(entry.key); return KeyedSubtree( key: ValueKey(entry.key), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _CollectionTrackTile( - entry: entry, - mode: widget.mode, - playlistId: widget.playlistId, - isSelectionMode: _isSelectionMode, - isSelected: isSelected, - onTap: _isSelectionMode - ? () => _toggleSelection(entry.key) - : null, - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(entry.key), - ), - if (index < entries.length - 1) - const Divider(height: 1), - ], + child: _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), ), ); }, childCount: entries.length), @@ -879,8 +872,6 @@ class _CollectionTrackTile extends ConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), leading: Row( mainAxisSize: MainAxisSize.min, children: [ From 83124875d36509049dbf793f0ed0f05ba121680e Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 19:31:18 +0700 Subject: [PATCH 17/38] fix: wrap collection folder list items in Card to match history item style in library tab --- lib/screens/queue_tab.dart | 71 +++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 6a5390cd..02f05e3f 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2845,40 +2845,49 @@ class _QueueTabState extends ConsumerState { ), ); - return InkWell( - onTap: onTap, - onLongPress: onLongPress, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - children: [ - SizedBox(width: 56, height: 56, child: cover), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + SizedBox(width: 56, height: 56, child: cover), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: 2), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), - ), - ], + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + ], + ), ), ), ); From f1d57d89c779ee109f7571cdf1a2692a03f72575 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 19:49:58 +0700 Subject: [PATCH 18/38] refactor: extract duplicated code to shared utilities across Dart and Go - Extract normalizeOptionalString() to lib/utils/string_utils.dart from download_queue_provider and track_metadata_screen - Extract PrioritySettingsScaffold widget from lyrics and metadata priority pages, reducing ~280 lines of duplication - Extract _ensureDefaultDocumentsOutputDir/_ensureDefaultAndroidMusicOutputDir in download queue provider - Extract collectLibraryAudioFiles() and applyDefaultLibraryMetadata() in Go library_scan.go - Extract plainTextLyricsLines() in Go lyrics.go, used by Apple Music, Musixmatch, and QQ Music clients --- go_backend/library_scan.go | 149 ++++----- go_backend/lyrics.go | 16 + go_backend/lyrics_apple.go | 13 +- go_backend/lyrics_musixmatch.go | 24 +- go_backend/lyrics_qqmusic.go | 13 +- lib/providers/download_queue_provider.dart | 132 ++++---- .../lyrics_provider_priority_page.dart | 312 +++++------------- .../metadata_provider_priority_page.dart | 199 +++-------- lib/screens/track_metadata_screen.dart | 19 +- lib/utils/string_utils.dart | 7 + lib/widgets/priority_settings_scaffold.dart | 147 +++++++++ 11 files changed, 452 insertions(+), 579 deletions(-) create mode 100644 lib/utils/string_utils.dart create mode 100644 lib/widgets/priority_settings_scaffold.dart diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index e0a1eafe..52e91fb8 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -67,6 +67,48 @@ var supportedAudioFormats = map[string]bool{ ".ogg": true, } +type libraryAudioFileInfo struct { + path string + modTime int64 +} + +func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) { + var files []libraryAudioFileInfo + + err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + select { + case <-cancelCh: + return fmt.Errorf("scan cancelled") + default: + } + + if info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if !supportedAudioFormats[ext] { + return nil + } + + files = append(files, libraryAudioFileInfo{ + path: path, + modTime: info.ModTime().UnixMilli(), + }) + return nil + }) + + if err != nil { + return nil, err + } + + return files, nil +} + func SetLibraryCoverCacheDir(cacheDir string) { libraryCoverCacheMu.Lock() libraryCoverCacheDir = cacheDir @@ -98,31 +140,16 @@ func ScanLibraryFolder(folderPath string) (string, error) { cancelCh := libraryScanCancel libraryScanCancelMu.Unlock() - var audioFiles []string - err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - - select { - case <-cancelCh: - return fmt.Errorf("scan cancelled") - default: - } - - if !info.IsDir() { - ext := strings.ToLower(filepath.Ext(path)) - if supportedAudioFormats[ext] { - audioFiles = append(audioFiles, path) - } - } - return nil - }) - + audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh) if err != nil { return "[]", err } + audioFiles := make([]string, 0, len(audioFileInfos)) + for _, fileInfo := range audioFileInfos { + audioFiles = append(audioFiles, fileInfo.path) + } + totalFiles := len(audioFiles) libraryScanProgressMu.Lock() libraryScanProgress.TotalFiles = totalFiles @@ -218,6 +245,18 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { } } +func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) { + if result.TrackName == "" { + result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + } + if result.ArtistName == "" { + result.ArtistName = "Unknown Artist" + } + if result.AlbumName == "" { + result.AlbumName = "Unknown Album" + } +} + func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadMetadata(filePath) if err != nil { @@ -243,15 +282,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul } } - if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - if result.ArtistName == "" { - result.ArtistName = "Unknown Artist" - } - if result.AlbumName == "" { - result.AlbumName = "Unknown Album" - } + applyDefaultLibraryMetadata(filePath, result) return result, nil } @@ -297,15 +328,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - if result.ArtistName == "" { - result.ArtistName = "Unknown Artist" - } - if result.AlbumName == "" { - result.AlbumName = "Unknown Album" - } + applyDefaultLibraryMetadata(filePath, result) return result, nil } @@ -337,15 +360,7 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - if result.ArtistName == "" { - result.ArtistName = "Unknown Artist" - } - if result.AlbumName == "" { - result.AlbumName = "Unknown Album" - } + applyDefaultLibraryMetadata(filePath, result) return result, nil } @@ -476,40 +491,14 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanCancelMu.Unlock() // Collect all audio files with their mod times - type fileInfo struct { - path string - modTime int64 - } - var currentFiles []fileInfo - currentPathSet := make(map[string]bool) - - err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - - select { - case <-cancelCh: - return fmt.Errorf("scan cancelled") - default: - } - - if !info.IsDir() { - ext := strings.ToLower(filepath.Ext(path)) - if supportedAudioFormats[ext] { - currentFiles = append(currentFiles, fileInfo{ - path: path, - modTime: info.ModTime().UnixMilli(), - }) - currentPathSet[path] = true - } - } - return nil - }) - + currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh) if err != nil { return "{}", err } + currentPathSet := make(map[string]bool, len(currentFiles)) + for _, fileInfo := range currentFiles { + currentPathSet[fileInfo.path] = true + } totalFiles := len(currentFiles) libraryScanProgressMu.Lock() @@ -517,7 +506,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanProgressMu.Unlock() // Find files to scan (new or modified) - var filesToScan []fileInfo + var filesToScan []libraryAudioFileInfo skippedCount := 0 for _, f := range currentFiles { diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 86c9087d..005a2ca9 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -651,6 +651,22 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine { return lines } +func plainTextLyricsLines(rawLyrics string) []LyricsLine { + var lines []LyricsLine + for _, line := range strings.Split(rawLyrics, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + lines = append(lines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + return lines +} + func lyricsHasUsableText(lyrics *LyricsResponse) bool { if lyrics == nil { return false diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 41d650f8..957db6fc 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -355,18 +355,7 @@ func (c *AppleMusicClient) FetchLyrics( } // Fall back to plain text if no timestamps found - plainLines := strings.Split(lrcText, "\n") - var resultLines []LyricsLine - for _, line := range plainLines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - resultLines = append(resultLines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + resultLines := plainTextLyricsLines(lrcText) if len(resultLines) > 0 { return &LyricsResponse{ diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index c3582b66..71d4544e 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -131,17 +131,7 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) // Fall back to unsynced lyrics for selected language if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - var lines []LyricsLine - for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - lines = append(lines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) if len(lines) > 0 { return &LyricsResponse{ @@ -187,17 +177,7 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec // Fall back to unsynced lyrics if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - var lines []LyricsLine - for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - lines = append(lines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) if len(lines) > 0 { return &LyricsResponse{ diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 6971ba24..0b76a49f 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -185,18 +185,7 @@ func (c *QQMusicClient) FetchLyrics( } // Fall back to plain text - plainLines := strings.Split(lrcText, "\n") - var resultLines []LyricsLine - for _, line := range plainLines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - resultLines = append(resultLines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + resultLines := plainTextLyricsLines(lrcText) if len(resultLines) > 0 { return &LyricsResponse{ diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 6860ea0f..8c3c37a0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -18,21 +18,16 @@ import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('DownloadQueue'); final _historyLog = AppLogger('DownloadHistory'); -String? _normalizeOptionalString(String? value) { - if (value == null) return null; - final trimmed = value.trim(); - if (trimmed.isEmpty) return null; - if (trimmed.toLowerCase() == 'null') return null; - return trimmed; -} - final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); final _trailingDotsRegex = RegExp(r'\.+$'); final _yearRegex = RegExp(r'^(\d{4})'); +const _defaultOutputFolderName = 'SpotiFLAC'; +const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName'; class DownloadHistoryItem { final String id; @@ -126,7 +121,7 @@ class DownloadHistoryItem { trackName: json['trackName'] as String, artistName: json['artistName'] as String, albumName: json['albumName'] as String, - albumArtist: _normalizeOptionalString(json['albumArtist'] as String?), + albumArtist: normalizeOptionalString(json['albumArtist'] as String?), coverUrl: json['coverUrl'] as String?, filePath: json['filePath'] as String, storageMode: json['storageMode'] as String?, @@ -451,14 +446,14 @@ class DownloadHistoryNotifier extends Notifier { ? item : item.copyWith( genre: - _normalizeOptionalString(item.genre) ?? - _normalizeOptionalString(existing.genre), + normalizeOptionalString(item.genre) ?? + normalizeOptionalString(existing.genre), label: - _normalizeOptionalString(item.label) ?? - _normalizeOptionalString(existing.label), + normalizeOptionalString(item.label) ?? + normalizeOptionalString(existing.label), copyright: - _normalizeOptionalString(item.copyright) ?? - _normalizeOptionalString(existing.copyright), + normalizeOptionalString(item.copyright) ?? + normalizeOptionalString(existing.copyright), ); if (existing != null) { @@ -1177,41 +1172,50 @@ class DownloadQueueNotifier extends Notifier { _lastNotifQueueCount = -1; } + Directory _defaultDocumentsOutputDir(String documentsPath) { + return Directory('$documentsPath/$_defaultOutputFolderName'); + } + + Directory _defaultAndroidMusicOutputDir(String storageRootPath) { + return Directory('$storageRootPath/$_defaultAndroidMusicSubpath'); + } + + Future _ensureDefaultDocumentsOutputDir() async { + final dir = await getApplicationDocumentsDirectory(); + final musicDir = _defaultDocumentsOutputDir(dir.path); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir; + } + + Future _ensureDefaultAndroidMusicOutputDir() async { + final dir = await getExternalStorageDirectory(); + if (dir == null) return null; + + final musicDir = _defaultAndroidMusicOutputDir( + dir.parent.parent.parent.parent.path, + ); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir; + } + Future _initOutputDir() async { if (state.outputDir.isEmpty) { try { if (Platform.isIOS) { - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); } else { - final dir = await getExternalStorageDirectory(); - if (dir != null) { - final musicDir = Directory( - '${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC', - ); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - state = state.copyWith(outputDir: musicDir.path); - } else { - final docDir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${docDir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - state = state.copyWith(outputDir: musicDir.path); - } + final musicDir = + await _ensureDefaultAndroidMusicOutputDir() ?? + await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); } } catch (e) { - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); } } @@ -1245,7 +1249,7 @@ class DownloadQueueNotifier extends Notifier { bool filterContributingArtistsInAlbumArtist = false, }) async { String baseDir = state.outputDir; - final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist); + final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName : track.artistName; @@ -1363,7 +1367,7 @@ class DownloadQueueNotifier extends Notifier { String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { var albumArtist = - _normalizeOptionalString(track.albumArtist) ?? track.artistName; + normalizeOptionalString(track.albumArtist) ?? track.artistName; if (settings.filterContributingArtistsInAlbumArtist) { albumArtist = _extractPrimaryArtist(albumArtist); } @@ -1396,7 +1400,7 @@ class DownloadQueueNotifier extends Notifier { bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, }) async { - final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist); + final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName : track.artistName; @@ -1902,10 +1906,10 @@ class DownloadQueueNotifier extends Notifier { ) { final backendTrackNum = _parsePositiveInt(backendResult['track_number']); final backendDiscNum = _parsePositiveInt(backendResult['disc_number']); - final backendYear = _normalizeOptionalString( + final backendYear = normalizeOptionalString( backendResult['release_date'] as String?, ); - final backendAlbum = _normalizeOptionalString( + final backendAlbum = normalizeOptionalString( backendResult['album'] as String?, ); @@ -2539,11 +2543,7 @@ class DownloadQueueNotifier extends Notifier { 'iOS: iCloud Drive path detected, falling back to app Documents folder', ); _log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing'); - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path); } else if (!isValidIosWritablePath(state.outputDir)) { @@ -2561,11 +2561,7 @@ class DownloadQueueNotifier extends Notifier { if (!isSafMode && state.outputDir.isEmpty) { _log.d('Using fallback directory...'); - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); } @@ -2964,10 +2960,10 @@ class DownloadQueueNotifier extends Notifier { } // Enrich track metadata from Deezer response (release_date, isrc, etc.) - final deezerReleaseDate = _normalizeOptionalString( + final deezerReleaseDate = normalizeOptionalString( trackData['release_date'] as String?, ); - final deezerIsrc = _normalizeOptionalString( + final deezerIsrc = normalizeOptionalString( trackData['isrc'] as String?, ); final deezerTrackNum = trackData['track_number'] as int?; @@ -4045,17 +4041,17 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; final effectiveGenre = - _normalizeOptionalString(backendGenre) ?? - _normalizeOptionalString(genre) ?? - _normalizeOptionalString(existingInHistory?.genre); + normalizeOptionalString(backendGenre) ?? + normalizeOptionalString(genre) ?? + normalizeOptionalString(existingInHistory?.genre); final effectiveLabel = - _normalizeOptionalString(backendLabel) ?? - _normalizeOptionalString(label) ?? - _normalizeOptionalString(existingInHistory?.label); + normalizeOptionalString(backendLabel) ?? + normalizeOptionalString(label) ?? + normalizeOptionalString(existingInHistory?.label); final effectiveCopyright = - _normalizeOptionalString(backendCopyright) ?? - _normalizeOptionalString(copyright) ?? - _normalizeOptionalString(existingInHistory?.copyright); + normalizeOptionalString(backendCopyright) ?? + normalizeOptionalString(copyright) ?? + normalizeOptionalString(existingInHistory?.copyright); _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index 58abaea3..b203717f 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class LyricsProviderPriorityPage extends ConsumerStatefulWidget { @@ -26,9 +26,8 @@ class _LyricsProviderPriorityPageState late List _initialProviders; bool _hasChanges = false; - List get _disabledProviders => _allProviderIds - .where((id) => !_enabledProviders.contains(id)) - .toList(); + List get _disabledProviders => + _allProviderIds.where((id) => !_enabledProviders.contains(id)).toList(); @override void initState() { @@ -39,204 +38,86 @@ class _LyricsProviderPriorityPageState } void _markChanged() { - final changed = _enabledProviders.length != _initialProviders.length || - !_enabledProviders - .asMap() - .entries - .every((e) => - e.key < _initialProviders.length && - _initialProviders[e.key] == e.value); + final changed = + _enabledProviders.length != _initialProviders.length || + !_enabledProviders.asMap().entries.every( + (e) => + e.key < _initialProviders.length && + _initialProviders[e.key] == e.value, + ); setState(() => _hasChanges = changed); } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); final disabled = _disabledProviders; - return PopScope( - canPop: !_hasChanges, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - final shouldPop = await _confirmDiscard(context); - if (shouldPop && context.mounted) { - Navigator.pop(context); - } - }, - child: Scaffold( - body: CustomScrollView( - slivers: [ - // ── Collapsing App Bar ── - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () async { - if (_hasChanges) { - final shouldPop = await _confirmDiscard(context); - if (shouldPop && context.mounted) { - Navigator.pop(context); - } - } else { - Navigator.pop(context); - } - }, - ), - actions: [ - if (_hasChanges) - TextButton( - onPressed: _saveChanges, - child: const Text('Save'), - ), - ], - 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); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: - EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - 'Lyrics Providers', - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), + return PrioritySettingsScaffold( + hasChanges: _hasChanges, + title: 'Lyrics Providers', + description: + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.', + infoText: + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.', + onSave: _saveChanges, + onConfirmDiscard: _confirmDiscard, + slivers: [ + if (_enabledProviders.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Enabled (${_enabledProviders.length})', ), - - // ── Description ── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - 'Enable, disable and reorder lyrics sources. ' - 'Providers are tried top-to-bottom until lyrics are found.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), + ), + if (_enabledProviders.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _enabledProviders.length, + itemBuilder: (context, index) { + final id = _enabledProviders[index]; + final info = _getLyricsProviderInfo(id); + return _EnabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + index: index, + isFirst: index == 0, + onToggle: () => _disableProvider(id), + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _enabledProviders.removeAt(oldIndex); + _enabledProviders.insert(newIndex, item); + }); + _markChanged(); + }, ), - - // ── Enabled section header ── - if (_enabledProviders.isNotEmpty) - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: 'Enabled (${_enabledProviders.length})', - ), - ), - - // ── Reorderable enabled list ── - if (_enabledProviders.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverReorderableList( - itemCount: _enabledProviders.length, - itemBuilder: (context, index) { - final id = _enabledProviders[index]; - final info = _getLyricsProviderInfo(id); - return _EnabledProviderItem( - key: ValueKey(id), - providerId: id, - info: info, - index: index, - isFirst: index == 0, - onToggle: () => _disableProvider(id), - ); - }, - onReorder: (oldIndex, newIndex) { - setState(() { - if (newIndex > oldIndex) newIndex -= 1; - final item = _enabledProviders.removeAt(oldIndex); - _enabledProviders.insert(newIndex, item); - }); - _markChanged(); - }, - ), - ), - - // ── Disabled section header ── - if (disabled.isNotEmpty) - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: 'Disabled (${disabled.length})', - ), - ), - - // ── Disabled list ── - if (disabled.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final id = disabled[index]; - final info = _getLyricsProviderInfo(id); - return _DisabledProviderItem( - key: ValueKey(id), - providerId: id, - info: info, - onToggle: () => _enableProvider(id), - ); - }, - childCount: disabled.length, - ), - ), - ), - - // ── Info banner ── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: - colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, - size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Extension lyrics providers always run before ' - 'built-in providers. At least one provider must ' - 'remain enabled.', - style: - Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), + ), + if (disabled.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Disabled (${disabled.length})', ), - - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], - ), - ), + ), + if (disabled.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final id = disabled[index]; + final info = _getLyricsProviderInfo(id); + return _DisabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + onToggle: () => _enableProvider(id), + ); + }, childCount: disabled.length), + ), + ), + ], ); } @@ -282,8 +163,7 @@ class _LyricsProviderPriorityPageState context: context, builder: (context) => AlertDialog( title: const Text('Discard changes?'), - content: - const Text('You have unsaved changes that will be lost.'), + content: const Text('You have unsaved changes that will be lost.'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -419,17 +299,15 @@ class _EnabledProviderItem extends StatelessWidget { children: [ Text( info.name, - style: - Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), ), Text( info.description, - style: - Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -438,18 +316,12 @@ class _EnabledProviderItem extends StatelessWidget { SizedBox( height: 32, child: FittedBox( - child: Switch( - value: true, - onChanged: (_) => onToggle(), - ), + child: Switch(value: true, onChanged: (_) => onToggle()), ), ), const SizedBox(width: 4), // Drag handle - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -498,8 +370,7 @@ class _DisabledProviderItem extends StatelessWidget { borderRadius: BorderRadius.circular(16), onTap: onToggle, child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ // Empty space aligned with numbered badge @@ -515,9 +386,7 @@ class _DisabledProviderItem extends StatelessWidget { children: [ Text( info.name, - style: Theme.of(context) - .textTheme - .bodyLarge + style: Theme.of(context).textTheme.bodyLarge ?.copyWith( fontWeight: FontWeight.w500, color: colorScheme.onSurfaceVariant, @@ -525,12 +394,8 @@ class _DisabledProviderItem extends StatelessWidget { ), Text( info.description, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: colorScheme.outline, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.outline), ), ], ), @@ -539,10 +404,7 @@ class _DisabledProviderItem extends StatelessWidget { SizedBox( height: 32, child: FittedBox( - child: Switch( - value: false, - onChanged: (_) => onToggle(), - ), + child: Switch(value: false, onChanged: (_) => onToggle()), ), ), ], diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 51a78e4e..7631e27d 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -2,16 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; class MetadataProviderPriorityPage extends ConsumerStatefulWidget { const MetadataProviderPriorityPage({super.key}); @override - ConsumerState createState() => _MetadataProviderPriorityPageState(); + ConsumerState createState() => + _MetadataProviderPriorityPageState(); } -class _MetadataProviderPriorityPageState extends ConsumerState { +class _MetadataProviderPriorityPageState + extends ConsumerState { late List _providers; bool _hasChanges = false; @@ -23,8 +25,10 @@ class _MetadataProviderPriorityPageState extends ConsumerState oldIndex) { - newIndex -= 1; - } - final item = _providers.removeAt(oldIndex); - _providers.insert(newIndex, item); - _hasChanges = true; - }); - }, - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.metadataProviderPriorityInfo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], + return PrioritySettingsScaffold( + hasChanges: _hasChanges, + title: context.l10n.metadataProviderPriorityTitle, + description: context.l10n.metadataProviderPriorityDescription, + descriptionPadding: const EdgeInsets.all(16), + infoText: context.l10n.metadataProviderPriorityInfo, + saveLabel: context.l10n.dialogSave, + onSave: _saveChanges, + onConfirmDiscard: _confirmDiscard, + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + return _MetadataProviderItem( + key: ValueKey(provider), + provider: provider, + index: index, + isFirst: index == 0, + isLast: index == _providers.length - 1, + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), ), - ), + ], ); } @@ -201,7 +106,9 @@ class _MetadataProviderPriorityPageState extends ConsumerState _saveChanges() async { - await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers); + await ref + .read(extensionProvider.notifier) + .setMetadataProviderPriority(_providers); setState(() { _hasChanges = false; }); @@ -300,10 +207,7 @@ class _MetadataProviderItem extends StatelessWidget { ], ), ), - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -312,7 +216,10 @@ class _MetadataProviderItem extends StatelessWidget { ); } - _MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) { + _MetadataProviderInfo _getProviderInfo( + BuildContext context, + String provider, + ) { switch (provider) { case 'deezer': return _MetadataProviderInfo( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index e5c70e8a..b8024977 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('TrackMetadata'); @@ -181,14 +182,6 @@ class _TrackMetadataScreenState extends ConsumerState { return cached.previewPath; } - String? _normalizeOptionalString(String? value) { - if (value == null) return null; - final trimmed = value.trim(); - if (trimmed.isEmpty) return null; - if (trimmed.toLowerCase() == 'null') return null; - return trimmed; - } - @override void initState() { super.initState(); @@ -206,7 +199,8 @@ class _TrackMetadataScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -382,7 +376,7 @@ class _TrackMetadataScreenState extends ConsumerState { String? get albumArtist { final edited = _editedMetadata?['album_artist']?.toString(); if (edited != null && edited.isNotEmpty) return edited; - return _normalizeOptionalString( + return normalizeOptionalString( _isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist, @@ -720,10 +714,7 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(height: 4), Text( albumName, - style: const TextStyle( - color: Colors.white54, - fontSize: 14, - ), + style: const TextStyle(color: Colors.white54, fontSize: 14), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart new file mode 100644 index 00000000..8394d430 --- /dev/null +++ b/lib/utils/string_utils.dart @@ -0,0 +1,7 @@ +String? normalizeOptionalString(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.toLowerCase() == 'null') return null; + return trimmed; +} diff --git a/lib/widgets/priority_settings_scaffold.dart b/lib/widgets/priority_settings_scaffold.dart new file mode 100644 index 00000000..f866b46f --- /dev/null +++ b/lib/widgets/priority_settings_scaffold.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; + +class PrioritySettingsScaffold extends StatelessWidget { + final bool hasChanges; + final String title; + final String description; + final String infoText; + final String saveLabel; + final EdgeInsetsGeometry descriptionPadding; + final List slivers; + final Future Function() onSave; + final Future Function(BuildContext context) onConfirmDiscard; + + const PrioritySettingsScaffold({ + super.key, + required this.hasChanges, + required this.title, + required this.description, + required this.infoText, + required this.slivers, + required this.onSave, + required this.onConfirmDiscard, + this.saveLabel = 'Save', + this.descriptionPadding = const EdgeInsets.fromLTRB(16, 4, 16, 8), + }); + + Future _handleBack(BuildContext context) async { + if (!hasChanges) { + Navigator.pop(context); + return; + } + final shouldPop = await onConfirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return PopScope( + canPop: !hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await onConfirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => _handleBack(context), + ), + actions: [ + if (hasChanges) + TextButton(onPressed: onSave, child: Text(saveLabel)), + ], + 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); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + title, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: descriptionPadding, + child: Text( + description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ...slivers, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + infoText, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } +} From c89600591c2cf900df0e177bb2bb8aa584a247ff Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 20 Feb 2026 03:30:34 +0700 Subject: [PATCH 19/38] feat: add love and add-to-playlist circular buttons to album screen Flanks the Download All button with two circular icon buttons: - Left: heart (favorite_border/favorite) toggles love for all tracks; turns red when all are loved - Right: plus icon opens the playlist picker sheet for all album tracks --- lib/screens/album_screen.dart | 129 ++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 14 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 555f7a1d..7e5d7d1c 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -12,6 +12,8 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; @@ -450,22 +452,29 @@ class _AlbumScreenState extends ConsumerState { ], ), const SizedBox(height: 16), - Center( - child: FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(tracks.length), - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), - ), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], ), ], ], @@ -579,6 +588,98 @@ class _AlbumScreenState extends ConsumerState { } } + Widget _buildLoveAllButton() { + final collectionsState = ref.watch(libraryCollectionsProvider); + final tracks = _tracks; + final allLoved = + tracks != null && + tracks.isNotEmpty && + tracks.every((t) => collectionsState.isLoved(t)); + + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: + tracks == null || tracks.isEmpty ? null : () => _loveAll(tracks), + icon: Icon( + allLoved ? Icons.favorite : Icons.favorite_border, + size: 22, + color: allLoved ? Colors.redAccent : Colors.white, + ), + tooltip: allLoved ? 'Remove from Loved' : 'Love All', + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildAddToPlaylistButton(BuildContext context) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: + _tracks == null || _tracks!.isEmpty + ? null + : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), + icon: const Icon(Icons.add, size: 22, color: Colors.white), + tooltip: 'Add to Playlist', + padding: EdgeInsets.zero, + ), + ); + } + + Future _loveAll(List tracks) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + final allLoved = tracks.every((t) => state.isLoved(t)); + + if (allLoved) { + for (final track in tracks) { + final key = trackCollectionKey(track); + await notifier.removeFromLoved(key); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Removed ${tracks.length} tracks from Loved'), + ), + ); + } + } else { + int addedCount = 0; + for (final track in tracks) { + if (!state.isLoved(track)) { + await notifier.toggleLoved(track); + addedCount++; + } + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added $addedCount tracks to Loved'), + ), + ); + } + } + } + void _navigateToArtist(BuildContext context, String artistName) { final artistId = _artistId ?? From ab26d8463237602a18b98cfab0a170f16253eb30 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 13:17:32 +0700 Subject: [PATCH 20/38] chore: rebuild dev history without streaming-era commits --- .github/workflows/release.yml | 4 +- AndroidManifest.xml | Bin 0 -> 16912 bytes CHANGELOG.md | 99 + README.md | 7 +- android/app/proguard-rules.pro | 10 + android/app/src/main/AndroidManifest.xml | 20 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 173 +- .../main/res/drawable/ic_stat_favorite.xml | 9 + .../res/drawable/ic_stat_favorite_border.xml | 9 + android/app/src/main/res/raw/keep.xml | 3 + .../main/res/xml/network_security_config.xml | 10 + go_backend/amazon.go | 124 +- go_backend/deezer.go | 75 +- go_backend/deezer_download.go | 352 ++ go_backend/exports.go | 97 +- go_backend/extension_manager.go | 21 +- go_backend/extension_providers.go | 24 +- go_backend/extension_runtime.go | 96 +- go_backend/extension_runtime_file.go | 23 +- go_backend/extension_runtime_storage.go | 269 +- go_backend/extension_runtime_storage_test.go | 120 + go_backend/lyrics.go | 190 + go_backend/qobuz.go | 282 +- go_backend/songlink.go | 39 +- go_backend/spotify.go | 70 +- go_backend/tidal.go | 759 +--- go_backend/title_match_utils.go | 27 + go_backend/title_match_utils_test.go | 18 + go_backend/youtube.go | 4 +- ios/Runner/AppDelegate.swift | 146 + ios/Runner/Info.plist | 6 + lib/app.dart | 30 +- lib/constants/app_info.dart | 4 +- lib/l10n/app_localizations.dart | 456 ++ lib/l10n/app_localizations_de.dart | 259 ++ lib/l10n/app_localizations_en.dart | 258 ++ lib/l10n/app_localizations_es.dart | 307 ++ lib/l10n/app_localizations_fr.dart | 259 ++ lib/l10n/app_localizations_hi.dart | 259 ++ lib/l10n/app_localizations_id.dart | 298 +- lib/l10n/app_localizations_ja.dart | 251 ++ lib/l10n/app_localizations_ko.dart | 251 ++ lib/l10n/app_localizations_nl.dart | 259 ++ lib/l10n/app_localizations_pt.dart | 307 ++ lib/l10n/app_localizations_ru.dart | 259 ++ lib/l10n/app_localizations_tr.dart | 258 ++ lib/l10n/app_localizations_zh.dart | 328 ++ lib/l10n/arb/app_de.arb | 15 +- lib/l10n/arb/app_en.arb | 166 + lib/l10n/arb/app_es.arb | 15 +- lib/l10n/arb/app_es_ES.arb | 15 +- lib/l10n/arb/app_fr.arb | 15 +- lib/l10n/arb/app_hi.arb | 15 +- lib/l10n/arb/app_id.arb | 298 +- lib/l10n/arb/app_ja.arb | 15 +- lib/l10n/arb/app_ko.arb | 15 +- lib/l10n/arb/app_nl.arb | 15 +- lib/l10n/arb/app_pt.arb | 15 +- lib/l10n/arb/app_pt_PT.arb | 15 +- lib/l10n/arb/app_ru.arb | 15 +- lib/l10n/arb/app_tr.arb | 15 +- lib/l10n/arb/app_zh.arb | 15 +- lib/l10n/arb/app_zh_CN.arb | 15 +- lib/l10n/arb/app_zh_TW.arb | 15 +- lib/models/playback_item.dart | 91 + lib/models/settings.dart | 24 + lib/models/settings.g.dart | 22 +- lib/models/track.dart | 4 + lib/models/track.g.dart | 4 + lib/providers/download_queue_provider.dart | 612 ++- lib/providers/extension_provider.dart | 313 +- lib/providers/local_library_provider.dart | 207 +- lib/providers/playback_provider.dart | 3880 +++++++++++++++++ lib/providers/settings_provider.dart | 30 +- lib/providers/track_provider.dart | 50 +- lib/screens/album_screen.dart | 108 +- lib/screens/artist_screen.dart | 68 +- lib/screens/downloaded_album_screen.dart | 16 +- lib/screens/home_tab.dart | 989 +++-- lib/screens/library_playlists_screen.dart | 30 +- lib/screens/library_tracks_folder_screen.dart | 327 +- lib/screens/local_album_screen.dart | 18 +- lib/screens/main_shell.dart | 228 +- lib/screens/playlist_screen.dart | 206 +- lib/screens/queue_tab.dart | 1089 +++-- lib/screens/search_screen.dart | 46 +- .../settings/appearance_settings_page.dart | 1 + lib/screens/settings/donate_page.dart | 105 +- .../settings/download_settings_page.dart | 148 +- lib/screens/settings/extensions_page.dart | 538 +-- .../lyrics_provider_priority_page.dart | 7 + .../settings/options_settings_page.dart | 46 +- .../settings/provider_priority_page.dart | 55 +- lib/screens/setup_screen.dart | 288 +- lib/screens/store_tab.dart | 175 +- lib/screens/track_metadata_screen.dart | 15 +- lib/services/csv_import_service.dart | 2 + lib/services/download_request_payload.dart | 4 + .../downloaded_embedded_cover_resolver.dart | 37 +- lib/services/ffmpeg_service.dart | 467 +- lib/services/platform_bridge.dart | 52 + lib/services/shell_navigation_service.dart | 30 + lib/services/update_checker.dart | 47 +- lib/utils/artist_utils.dart | 15 + lib/utils/clickable_metadata.dart | 577 +++ lib/widgets/download_service_picker.dart | 3 +- lib/widgets/mini_player_bar.dart | 2040 +++++++++ lib/widgets/playlist_picker_sheet.dart | 383 +- .../track_collection_quick_actions.dart | 384 +- pubspec.lock | 130 +- pubspec.yaml | 12 +- 111 files changed, 18282 insertions(+), 3459 deletions(-) create mode 100644 AndroidManifest.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_favorite.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_favorite_border.xml create mode 100644 android/app/src/main/res/raw/keep.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 go_backend/deezer_download.go create mode 100644 go_backend/extension_runtime_storage_test.go create mode 100644 lib/models/playback_item.dart create mode 100644 lib/providers/playback_provider.dart create mode 100644 lib/services/shell_navigation_service.dart create mode 100644 lib/utils/artist_utils.dart create mode 100644 lib/utils/clickable_metadata.dart create mode 100644 lib/widgets/mini_player_bar.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3198bbcd..04ade01e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version: "1.25.7" cache-dependency-path: go_backend/go.sum # Cache Gradle for faster builds @@ -174,7 +174,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version: "1.25.7" cache-dependency-path: go_backend/go.sum # Cache CocoaPods diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..e8ef04455a7ef64a3f0ebbcaff7b43990851d265 GIT binary patch literal 16912 zcmc(mTZ~>+7035i@U*S>Ly3f9K46Uv1)< ztgiE|v)6xJ_dffa1NE-iwQO*%&Mk2_eZsk?T=`f(t$aRLr%ObuM7N1LMaM*EMbC&{ z7QH2UPc*kqV?{TMHi>#g`$Uh6o)f(y`jf~B{}ZCMqK8E1L@$fp5WOwBwBEV(qEXTF zqTh=CDq7ay+zq0&qI*Omq9;YqiheBmmFQj3x<>gYdQIe}JGWExg6KWbswVj)dP(%Q z=%!|k75zcfFvGcRqE|)BW;*wP=oOKhrC3BSh~5$1Hru(UMQ@0v&2etG==-AgMeCHX zXG9-}cF)ro(b^XIDEdIuIbVK@UKf2Nx@m!Pn?(+y3e~G#-ldfn~^sMM((a>V&zAbuN^r2|_<<2b^of5qz`dHL*g>x%JouU!Z zInj4TZ;GZ}sTzn@iPnp{LL63AmcAQ)24oT7{jB&TaU+a-h>Ioxf`bdszWWVCuD*OSBbZ$-- z(Lv!JavOCGv_ravR--)aljd6I?|6>7B5o%DzU8hcW>!|tFgb;!nu&ssUw z7Rmc$kLbn=JeVWYK2;#!zmS1PggfYSL3I}ToQ*1;lOFwmYg6=Ohni9ytK)qAKBZsC z=m%n;E9iz!@%_q#bBlyNCdrtp0RkR_fM&1PJ*WujM5-D(V>rv?pst*je4e}s9%5&M zazYojl{|Fr66J?1oYa+3Rbsn-_4~TgU#Df8J`Y{t+}fbaqVum z>a-ISFQQgt(n)Of4jV-SK2dlm6+*+&FX?Sjba9c$ZcLUmwpo`^>MCA zGU8{285DQ!vP9CV-(|2lU?O($5(BO z{w1ots(rg;p_U;xwRzVggl_dgyLw~0@W?Pd<=o1eyt`kKbqHrmR5`kDm+-JaytU;! zbTZYhO>d|CXqVloyM*~l{~c0SGwbO(eBGr?wCS9Vb8hJrd8kC$B)!c-*dYWmxvXXc z>bXNXV@{Jl_Ky>1V5`3)vGVrG@BMyep7d*ZtL8oH30F2m@=-+f-&f8lsqc=t! z!g|uH9CgdDE$W!<`qd^PSCy+mKH?@t$Li$Vbro^tYi#!R+ST<`h}>-Qw#g0^r-PkV z%QQNv#yW8Wd!5;o%TG%SDZEXm%c#mpEzPBivE334{szm;`iBlWx@~hNj zP3sbcwIs~^fYYYl4Yj4tVMO)SV&>Bqdxg#_-l6$GjmT$*Bwf0i*VFmcVvTF_^`6?p zYpaaBj2YK}Sw}s(3sxvYuFmw|NPX-RC=WMH1U7>9RXzUs&Gpu$x3O^{z6#H&4U^Io=U%)*8-! zvd6FD?8bPG8qMhfaCib5b`NXRTK%$Je5;}~x%tOkk7t(fbjPl9(4EPUi7MXdfz0)T zy56sQWWV>QHS>vGFi)X8QA`|Bgnx4met2?a|4+2+3VD7DyJ6xrYc>Mx@`!ETGxT`2 zAv?EUbQ|CnMU>tV$k@~orMq2r?cD8IHNt(hTRCPeF0Yw-Gju_bO|(_H5-+1VR;-IC*V=n-Z-v%hk`gYAi9T63ygeuk->hv}LL zV*G}HS;@0B&p7txf#>dYpH!*GXl6UROM063GWpL{&3;V0kLBLQT5?q0^h>+X&u%1P z{buh-DX3nu97OgcMB`mZoDDnV@O+8R zc1d{eH6Tr_52*L^N88(k$~oTtfAp%-o0TPR4lC+@#mFia>R$P5Tg|gl8IdOw)?_lq zJHK$}Ij*dcQGS=-NU&G8E9q`O+_~t9+7RQXiBM_oQk6MaP|Cp>-?L|AX-s`)mF4Xw z?~(4RYFw*!JnXsKv?8)|3{Sf3@;m&>!ks$oEyIq09p^T`L*Tg}tej}_R2z1jJh$7d zn9M^g^LFo;@8&+`InCtKDsi?dV)~#>#ME~6W12Hi8sch>iE6tE7qWRPt>4Fu~zq~$7bsMfd0a+#yr$p z`$RsX(Y&Dzdz@qbt=lbr%n8XR+7CV-)%*%OC*BwEo-3?_W#3re%+KOg^^S{Zr6=>E zN0G31qvsB(>&q(;kxz_MHrkoSjp`SQRUS>Age%W}bPbeE9!YZV$4KbbSTTcpEE9b#lJ&b=Hj zEN9_c2Kfy8X2$YuhQ zOTY7OIdnbqB=334xy6zb_qeJNa=1Yj*&z%lZYpEDnT@*6T&qQ=OUU7SO0(>w^SH_J z%>x_cmyQWvLU`6F@9~>-CBE|!3;AY$vL^GMz9Or6`JXKtAdD&3REsxgtS#Y-M)_KP zewrigF~!f@`nl=_{#t!LD%T!;C#`FC&uOH-8rRi{ritoB(?v5xb44wp#iA=kSBaL2 zmW!?vtq`pgtrE?irZJ)=q8mjcqDMqei~cBjr=hOy@1l1_jg8V1jf%!ZXGGr-JvhCt zZb;;0Ri8QetnAL~xKVtV-;)0%l5_2ZelOCWKK=TGaSKFT(AeR zOkNtdJd-UIzfQzBI5=m-S;djTkv*DmY@mzmu}G#*!`|OcaeU)C$CEn8Ut;6H4}CWF z*TEugWcn;zl7U~J&(|fNuhO~MNU_WYISN@hf8KmfZEVmOY`{|+%Oo=!DVF7Bk;bGp(!AWG zG1!0`UaDt!Ru?eM=M;~xSm418FV!oYvK;iYHV@^vi&0J2j{-fC?ZFa&7Y=ovyoz%&$njd(J87?Y{T$UJ@DQV*6k7A z9-ZU!3~>(pW3H|e;WHc=er7)F@|-7-5gU1C92}XgeUh=Y@iJS#Vwv@$WOa>hfyY~> z@moaT!NI#mWOf^}an|GDagDgxYq3}0HwXPmTjlwuf=9Gh^)*}lc_ zky~s9yY=2j_@APIX3Aj z4q_lK#(@Vnyj0KRX$%(IcIi$PTW>U`D7M=&U*Kjl)w9@kMtDWB-I?LR4KLL*yxkFA z(fWTb!-E@Ms%P>vCuZlN$d2_vToc<9#63!FJ{Rc~<&X7*TEfkbRL}g_8{yTGzf{lg z_Emv*hsNL^-26-Rz@yLhi>#-t9?W+RdUu{^LJ$JQm^A(rc-n;XPgj zUS6-IdRC*e5ne65_9f|2Be>Nf#WEX=v7FXo<9KERZZ=XZlc)8ze0?plT}vN)J<_YC z4^lnz;~NoPEq##c8QwRmz{~4{RL^Sktq8A{8l93JeE_#wq*x|TYhm(qUyD!sg!in- z`sRx|XP;Fs&cS_&^L`Qb;mDdq=t#Cer(Sk-SvH?Nf!R&*&F=HrcyNmBa=*qdJhh7+ zve+*7Sn%1iz9?c0hxMdH{$LFIlJAMah~nq)PiYK3z%8a!&*U#gc-S;~8Y}iYb^Qm? z__I1E_whb~-0u@%AD)*3i}#1RZm|@_`=e+~(R{mEW3UG||5H7ar#4NV#%1xo9N9ms z^SpT3^I;#J#w$_DrxeR<{8VEs4`+3rXM?>HHsEnSqMWYI?7=NZ zsU9}z$)8t&cT0u`H@sBO@P1JR-mMuP-0)I8!+Wg?yw7KNaKlUWOrGZ0HFYyH%@Vp*ErtA8C z7skPn%@Ae2l-@gl@taJ}wW7E_t1;MwgEc49k*rQjap^fe#j<$QxGdh^WjNR_iuXZ{ z!8Uxdcn@Xc;FHDsdtJvK$A!dO7sZ=mS-fdn7Vle;?V@;xvN+(A#d|m#2cIn7KkIsk z_QK+w7R8%lS-fdnHfR14**>fDyg74Ja_S4uix({Jt8a@ej-oa3Z_${dHF2fJU=MEo zr+Ow&ZJIod3m^9h?;j%b@dlj}FLRcI*f_W@`*lsSj*RQfeSCuF<-qKw_?GAQrDt}( ztn<7)@5*ezv2mFw^JRffOSwp~%*H>n@#qxU*sU?d0iVo9ie>rxcVxrnN1Q*-cV;%= zdAUL+AM}Mk_yV^W{-bltQ_3It6w7R|B2HzaE5n0NW+TNi8}(J# z=*eurC$o`aS$t_Nu>BO6k?phkO|LbIb6|%Wb&Kc&I5O^GcV#j-vgso7q;dX!gsdq? zwj`42w6xFW|CzTu7p({LqA^A5!8T#yE8JpF^-P}HBqyv*Ja|my%YtZ3kuSZOFL1M& z>X|&XX}&Cq>=fnYl4wkkFT9H-FL1M&>X|&XX}(+**(vg6aWtmLm-{nc;AS(`GkI#$ i **Major update warning:** This release introduces a large streaming-focused refactor and broad cross-app behavior changes. +> +> **Diff scope (`cdc583678558223ecbb552176b53727d303ae218..HEAD`):** 121 files changed, 28,354 insertions(+), 4,598 deletions(-). + +### Added + +- **End-to-End Streaming Mode**: Full streaming playback flow with full-screen player, synced lyrics, media controls, and queue-aware tap behavior across album, artist, playlist, home, and search screens +- **Smart Queue System**: ML-based queue auto-curation with related artist discovery, plus a dedicated playback queue view +- **DASH Streaming Pipeline**: Native DASH manifest playback support with local proxy integration and FFmpeg tunnel fallback for unsupported paths +- **Playback State Persistence**: Player state and queue continuity restored across app restarts +- **Adaptive Playback Engine**: EventChannel-driven playback/progress updates (replacing polling) and adaptive prefetch behavior +- **Queue Reliability Controls**: New auto-skip unavailable tracks option during queue playback +- **Player Quick Action**: New download button in full-screen player top bar +- **Metadata Control**: New global master switch for embed metadata behavior +- **Setup Flow Update**: Initial setup now prioritizes mode selection instead of Spotify API setup +- **Library Workflow Expansion**: Playlist-first library redesign, drag-and-drop categorization, folder multi-select, and batch playlist picker flows +- **SongLink Region Setting**: Region selection support for metadata/linking behavior +- **Track Interaction UX**: Long-press context menus for track actions across major collection screens +- **Batch Tools**: Multi-select share, batch convert, and batch re-enrich improvements for downloaded/local/queue workflows + +### Changed + +- **Global Mode-Driven Actions**: Interaction mode now drives behavior app-wide (download-oriented vs streaming-oriented actions) +- **UI Redesign and Responsiveness**: Full-screen cover/parallax rollout and responsive fixes for filter sheets and full-screen player in small screens/landscape +- **Performance Optimizations**: Granular Riverpod consumers, selective provider watching, computation caching, debounced extension storage writes, and lifecycle cleanups +- **Lyrics Loading Strategy**: Lyrics are now lazy-loaded only when the lyrics view is visible +- **Persistence Backend Refactor**: Core persistence paths migrated to SQLite-backed stores for app state and library collections +- **Shared Code Refactor**: Duplicated logic extracted into shared Dart/Go utilities for cleaner boundaries and maintainability + +### Fixed + +- **iOS Build Compatibility**: Resolved `RepeatMode` naming collision with Flutter SDK symbols +- **Playback Completion Handling**: Fixed track completion restart issues and queue-end completion synchronization +- **Streaming Stability**: Added guards for playback race conditions during queue/stream state transitions +- **Provider I/O Safety**: Improved Android/Go file descriptor handling for SAF-based outputs +- **Metadata Matching Robustness**: Improved title matching with strict emoji handling and name+artist fallback lookup behavior +- **Navigation Behavior**: Back button now exits app correctly instead of unexpectedly returning to home + +--- + +## [4.0.0] - 2026-02-22 + +### Added + +- **Interaction Mode Setting**: New "Interaction Mode" toggle in Options settings to switch between Downloader Mode (tap to queue downloads) and Streaming Mode (tap to play instantly) + - Affects album, artist discography, playlist, home explore, and search screens + - All action buttons (Download All, Download Selected, Download Discography) dynamically switch to Play equivalents when in Streaming Mode +- **Streaming Playback Integration**: Tapping tracks in Streaming Mode plays them via `playTrackStreamAndSetQueue` with full queue support across all collection screens (album, artist, playlist, home, search) +- **Long-Press Track Context Menus**: Added `onLongPress` handler on track items across album, artist, home, playlist, and search screens to open the track options bottom sheet via `TrackCollectionQuickActions.showTrackOptionsSheet` +- **USDT TRC20 Crypto Donation**: Added USDT (TRC20) wallet address to Donate page with tap-to-copy-to-clipboard functionality and snackbar confirmation +- **Localization**: Added interaction mode and streaming playback strings across all 14 supported locales (`optionsInteractionMode`, `modeDownloader`, `modeDownloaderSubtitle`, `modeStreaming`, `modeStreamingSubtitle`, `playAllCount`, `discographyPlay`, `discographyPlayAll`, `discographyPlaySelected`) +- **Indonesian (ID) Localization**: Full translations for all new streaming mode strings + +### Changed + +- **Mini Player Bar Layout**: Media section (cover art / lyrics) now uses fixed-height `SizedBox` (50% screen height, clamped 300–560px) instead of `Expanded` for more consistent layout +- **Lyrics Font Size Increase**: Synced lyrics current line 22→24px, non-current 18→19px; word-by-word highlight 22→24px; unsynced 18→19px +- **Playback Media Controls**: Removed stop button from notification media controls for cleaner transport bar +- **Playback Queue Exhaustion**: Player now properly syncs `ProcessingState.completed` state when queue is exhausted instead of silently stopping +- **`TrackCollectionQuickActions.showTrackOptionsSheet` Made Static**: Extracted to a public static method so all screens can invoke it directly for long-press handling +- **Bottom Spacing in Mini Player**: Reduced from 16px to 4px for tighter layout + +### Fixed + +- **Playback State Not Updating on Queue End**: Fixed playback notification staying in "playing" state when all tracks in queue have finished + +--- + ## [3.7.0] - 2026-02-19 ### Added diff --git a/README.md b/README.md index d18e8b58..f3e14cda 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum ## API Credits -- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net) -- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev) -- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz) -- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy) -- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com) -- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify) +[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify) > [!TIP] diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 2bd4f8d5..d9752c2c 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -80,6 +80,16 @@ -keep class io.flutter.plugins.pathprovider.** { *; } -keep class dev.flutter.pigeon.** { *; } +# Audio Service (media playback notification) - CRITICAL for release builds +-keep class com.ryanheise.audioservice.** { *; } +-keep class com.ryanheise.audio_session.** { *; } +-keep class com.ryanheise.just_audio.** { *; } + +# AndroidX Media / MediaSession (used by audio_service) +-keep class androidx.media.** { *; } +-keep class android.support.v4.media.** { *; } +-dontwarn android.support.v4.media.** + # Local Notifications -keep class com.dexterous.** { *; } -keep class com.dexterous.flutterlocalnotifications.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9043277b..13a82560 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + @@ -21,6 +22,7 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="false" + android:networkSecurityConfig="@xml/network_security_config" android:enableOnBackInvokedCallback="true" android:localeConfig="@xml/locale_config"> @@ -92,6 +94,24 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index bd3014c5..1c8769d2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,20 +4,25 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile +import com.ryanheise.audioservice.AudioServiceFragmentActivity import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode import io.flutter.embedding.android.FlutterFragment -import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.RenderMode import io.flutter.embedding.android.TransparencyMode import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterShellArgs +import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel import gobackend.Gobackend import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray @@ -27,13 +32,24 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.Locale -class MainActivity: FlutterFragmentActivity() { +class MainActivity: AudioServiceFragmentActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" + private val DOWNLOAD_PROGRESS_STREAM_CHANNEL = + "com.zarz.spotiflac/download_progress_stream" + private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = + "com.zarz.spotiflac/library_scan_progress_stream" + private val STREAM_POLLING_INTERVAL_MS = 800L private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null private val safScanLock = Any() private val safDirLock = Any() private var safScanProgress = SafScanProgress() + private var downloadProgressStreamJob: Job? = null + private var downloadProgressEventSink: EventChannel.EventSink? = null + private var lastDownloadProgressPayload: String? = null + private var libraryScanProgressStreamJob: Job? = null + private var libraryScanProgressEventSink: EventChannel.EventSink? = null + private var lastLibraryScanProgressPayload: String? = null @Volatile private var safScanCancel = false @Volatile private var safScanActive = false private val safTreeLauncher = registerForActivityResult( @@ -380,6 +396,78 @@ class MainActivity: FlutterFragmentActivity() { return obj.toString() } + private fun readLibraryScanProgressJsonForStream(): String { + return if (safScanActive) { + safProgressToJson() + } else { + Gobackend.getLibraryScanProgressJSON() + } + } + + private fun startDownloadProgressStream(sink: EventChannel.EventSink) { + stopDownloadProgressStream() + downloadProgressEventSink = sink + lastDownloadProgressPayload = null + downloadProgressStreamJob = scope.launch { + while (isActive && downloadProgressEventSink === sink) { + try { + val payload = withContext(Dispatchers.IO) { + Gobackend.getAllDownloadProgress() + } + if (payload != lastDownloadProgressPayload) { + lastDownloadProgressPayload = payload + sink.success(payload) + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Download progress stream poll failed: ${e.message}", + ) + } + delay(STREAM_POLLING_INTERVAL_MS) + } + } + } + + private fun stopDownloadProgressStream() { + downloadProgressStreamJob?.cancel() + downloadProgressStreamJob = null + downloadProgressEventSink = null + lastDownloadProgressPayload = null + } + + private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) { + stopLibraryScanProgressStream() + libraryScanProgressEventSink = sink + lastLibraryScanProgressPayload = null + libraryScanProgressStreamJob = scope.launch { + while (isActive && libraryScanProgressEventSink === sink) { + try { + val payload = withContext(Dispatchers.IO) { + readLibraryScanProgressJsonForStream() + } + if (payload != lastLibraryScanProgressPayload) { + lastLibraryScanProgressPayload = payload + sink.success(payload) + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Library scan progress stream poll failed: ${e.message}", + ) + } + delay(STREAM_POLLING_INTERVAL_MS) + } + } + } + + private fun stopLibraryScanProgressStream() { + libraryScanProgressStreamJob?.cancel() + libraryScanProgressStreamJob = null + libraryScanProgressEventSink = null + lastLibraryScanProgressPayload = null + } + private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { val obj = JSONObject() if (treeUriStr.isBlank() || fileName.isBlank()) { @@ -1252,16 +1340,79 @@ class MainActivity: FlutterFragmentActivity() { return respObj.toString() } + // Disable Flutter's built-in deep linking so that incoming ACTION_VIEW URLs + // (Spotify, Deezer, Tidal, YouTube Music) are NOT forwarded to GoRouter. + // We handle these URLs ourselves via receive_sharing_intent + ShareIntentService. + override fun shouldHandleDeeplinking(): Boolean = false + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Update the intent so receive_sharing_intent can access the new data setIntent(intent) } + override fun onDestroy() { + try { + Gobackend.cleanupExtensions() + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to cleanup extensions on destroy: ${e.message}") + } + stopDownloadProgressStream() + stopLibraryScanProgressStream() + super.onDestroy() + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + // Always-enabled back callback to ensure back presses reach Flutter. + // Nested tab navigators can incorrectly set frameworkHandlesBack(false), + // which disables Flutter's own OnBackPressedCallback and causes the + // system default (finish activity) to run. This callback guarantees + // popRoute is always forwarded to Flutter, where PopScope handles it. + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + flutterEngine.navigationChannel.popRoute() + } + }) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + + EventChannel( + messenger, + DOWNLOAD_PROGRESS_STREAM_CHANNEL, + ).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + if (events != null) { + startDownloadProgressStream(events) + } + } + + override fun onCancel(arguments: Any?) { + stopDownloadProgressStream() + } + }, + ) + + EventChannel( + messenger, + LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL, + ).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + if (events != null) { + startLibraryScanProgressStream(events) + } + } + + override fun onCancel(arguments: Any?) { + stopLibraryScanProgressStream() + } + }, + ) + + MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result -> scope.launch { try { when (call.method) { @@ -1296,6 +1447,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getSpotifyRelatedArtists" -> { + val artistId = call.argument("artist_id") ?: "" + val limit = call.argument("limit") ?: 12 + val response = withContext(Dispatchers.IO) { + Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong()) + } + result.success(response) + } "checkAvailability" -> { val spotifyId = call.argument("spotify_id") ?: "" val isrc = call.argument("isrc") ?: "" @@ -1973,6 +2132,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getDeezerRelatedArtists" -> { + val artistId = call.argument("artist_id") ?: "" + val limit = call.argument("limit") ?: 12 + val response = withContext(Dispatchers.IO) { + Gobackend.getDeezerRelatedArtists(artistId, limit.toLong()) + } + result.success(response) + } "getDeezerMetadata" -> { val resourceType = call.argument("resource_type") ?: "" val resourceId = call.argument("resource_id") ?: "" diff --git a/android/app/src/main/res/drawable/ic_stat_favorite.xml b/android/app/src/main/res/drawable/ic_stat_favorite.xml new file mode 100644 index 00000000..6ef85758 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_favorite.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_stat_favorite_border.xml b/android/app/src/main/res/drawable/ic_stat_favorite_border.xml new file mode 100644 index 00000000..7e803abf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_favorite_border.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..c71ae08e --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..cde84d83 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + localhost + 127.0.0.1 + + diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 80fc9f3c..b30d669d 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -45,7 +45,7 @@ type AfkarXYZResponse struct { } `json:"data"` } -// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin} +// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin} type AmazonStreamResponse struct { StreamURL string `json:"streamUrl"` DecryptionKey string `json:"decryptionKey"` @@ -179,7 +179,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() - apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin) + apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", "", fmt.Errorf("failed to create request: %w", err) @@ -193,13 +193,13 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st } defer resp.Body.Close() - if resp.StatusCode != 200 { - return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", "", "", fmt.Errorf("failed to read response: %w", readErr) } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", "", fmt.Errorf("failed to read response: %w", err) + if resp.StatusCode != 200 { + return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) } var apiResp AmazonStreamResponse @@ -219,7 +219,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, st ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() - apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) + apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", "", fmt.Errorf("failed to create legacy request: %w", err) @@ -375,6 +375,57 @@ type AmazonDownloadResult struct { DecryptionKey string } +func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) { + if strings.TrimSpace(logPrefix) == "" { + logPrefix = "Amazon" + } + + amazonURL := "" + if req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" { + amazonURL = cached.AmazonURL + GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC) + } + } + + if amazonURL != "" { + return amazonURL, nil + } + + songlink := NewSongLinkClient() + var availability *TrackAvailability + var err error + + deezerID := strings.TrimSpace(req.DeezerID) + if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" { + deezerID = strings.TrimSpace(prefixedDeezerID) + } + + if deezerID != "" { + GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID) + availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) + } else if req.SpotifyID != "" { + availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + } else { + return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") + } + + if err != nil { + return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) + } + + if availability == nil || !availability.Amazon || availability.AmazonURL == "" { + return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") + } + + amazonURL = availability.AmazonURL + if req.ISRC != "" { + GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL) + } + + return amazonURL, nil +} + func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() @@ -385,40 +436,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - amazonURL := "" - if req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" { - amazonURL = cached.AmazonURL - GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC) - } - } - - songlink := NewSongLinkClient() - var availability *TrackAvailability - var err error - - if amazonURL == "" { - if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found { - GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) - availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) - } else if req.SpotifyID != "" { - availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - } else { - return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") - } - - if err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) - } - - if !availability.Amazon || availability.AmazonURL == "" { - return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") - } - - amazonURL = availability.AmazonURL - if req.ISRC != "" { - GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL) - } + amazonURL, err := resolveAmazonURLForRequest(req, "Amazon") + if err != nil { + return AmazonDownloadResult{}, err } if !isSafOutput && req.OutputDir != "." { @@ -467,13 +487,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, + coverURL, req.EmbedMaxQualityCover, req.SpotifyID, req.TrackName, req.ArtistName, - req.EmbedLyrics, + embedLyrics, int64(req.DurationMS), ) }() @@ -560,8 +586,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - if isSafOutput || needsDecryption { - GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + if isSafOutput || needsDecryption || !req.EmbedMetadata { + if !req.EmbedMetadata { + GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") + } else { + GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } } else { isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac") if isFlacOutput { @@ -641,7 +671,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } lyricsLRC := "" - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 09254fc2..568e61c4 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -13,12 +13,13 @@ import ( ) const ( - deezerBaseURL = "https://api.deezer.com/2.0" - deezerSearchURL = deezerBaseURL + "/search" - deezerTrackURL = deezerBaseURL + "/track/%s" - deezerAlbumURL = deezerBaseURL + "/album/%s" - deezerArtistURL = deezerBaseURL + "/artist/%s" - deezerPlaylistURL = deezerBaseURL + "/playlist/%s" + deezerBaseURL = "https://api.deezer.com/2.0" + deezerSearchURL = deezerBaseURL + "/search" + deezerTrackURL = deezerBaseURL + "/track/%s" + deezerAlbumURL = deezerBaseURL + "/album/%s" + deezerArtistURL = deezerBaseURL + "/artist/%s" + deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related" + deezerPlaylistURL = deezerBaseURL + "/playlist/%s" deezerCacheTTL = 10 * time.Minute @@ -234,6 +235,8 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { DiscNumber: track.DiskNumber, ExternalURL: track.Link, ISRC: track.ISRC, + AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID), + ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID), } } @@ -756,6 +759,66 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR return result, nil } +func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { + normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:")) + if normalizedArtistID == "" { + return nil, fmt.Errorf("invalid Deezer artist ID") + } + + effectiveLimit := limit + if effectiveLimit <= 0 { + effectiveLimit = 12 + } + + relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit) + var relatedResp struct { + Data []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXL string `json:"picture_xl"` + NbFan int `json:"nb_fan"` + } `json:"data"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error,omitempty"` + } + + if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil { + return nil, err + } + if relatedResp.Error != nil { + return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message) + } + + result := make([]SearchArtistResult, 0, len(relatedResp.Data)) + for _, artist := range relatedResp.Data { + imageURL := artist.PictureXL + if imageURL == "" { + imageURL = artist.PictureBig + } + if imageURL == "" { + imageURL = artist.PictureMedium + } + if imageURL == "" { + imageURL = artist.Picture + } + + result = append(result, SearchArtistResult{ + ID: fmt.Sprintf("deezer:%d", artist.ID), + Name: artist.Name, + Images: imageURL, + Followers: artist.NbFan, + Popularity: 0, + }) + } + return result, nil +} + func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go new file mode 100644 index 00000000..fa613394 --- /dev/null +++ b/go_backend/deezer_download.go @@ -0,0 +1,352 @@ +package gobackend + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const deezerYoinkifyURL = "https://yoinkify.lol/api/download" + +type YoinkifyRequest struct { + URL string `json:"url"` + Format string `json:"format"` + GenreSource string `json:"genreSource"` +} + +type DeezerDownloadResult struct { + FilePath string + BitDepth int + SampleRate int + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + DiscNumber int + ISRC string + LyricsLRC string +} + +func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) { + rawSpotify := strings.TrimSpace(req.SpotifyID) + if rawSpotify != "" { + if isLikelySpotifyTrackID(rawSpotify) { + return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil + } + + if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" { + return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil + } + } + + deezerID := strings.TrimSpace(req.DeezerID) + if deezerID == "" { + if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found { + deezerID = strings.TrimSpace(prefixed) + } + } + + if deezerID != "" { + songlink := NewSongLinkClient() + spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID) + if err != nil { + return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err) + } + spotifyID = strings.TrimSpace(spotifyID) + if spotifyID == "" { + return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID) + } + return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil + } + + return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify") +} + +func isLikelySpotifyTrackID(value string) bool { + if len(value) != 22 { + return false + } + for _, r := range value { + switch { + case r >= 'A' && r <= 'Z': + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + default: + return false + } + } + return true +} + +func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error { + payload := YoinkifyRequest{ + URL: spotifyURL, + Format: "flac", + GenreSource: "spotify", + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode Yoinkify request: %w", err) + } + + ctx := context.Background() + if itemID != "" { + StartItemProgress(itemID) + defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + } + + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create Yoinkify request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "*/*") + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("failed to call Yoinkify: %w", err) + } + defer resp.Body.Close() + + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + bodyText := strings.TrimSpace(string(bodyBytes)) + if bodyText != "" { + return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText) + } + return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode) + } + + if strings.Contains(contentType, "application/json") { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + bodyText := strings.TrimSpace(string(bodyBytes)) + if bodyText == "" { + bodyText = "empty JSON payload" + } + return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText) + } + + expectedSize := resp.ContentLength + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) + } + + out, err := openOutputForWrite(outputPath, outputFD) + if err != nil { + return err + } + + bufWriter := bufio.NewWriterSize(out, 256*1024) + var written int64 + if itemID != "" { + pw := NewItemProgressWriter(bufWriter, itemID) + written, err = io.Copy(pw, resp.Body) + } else { + written, err = io.Copy(bufWriter, resp.Body) + } + + flushErr := bufWriter.Flush() + closeErr := out.Close() + + if err != nil { + cleanupOutputOnError(outputPath, outputFD) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to flush output: %w", flushErr) + } + if closeErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to close output: %w", closeErr) + } + + if expectedSize > 0 && written != expectedSize { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024)) + return nil +} + +func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { + deezerClient := GetDeezerClient() + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } + } + + spotifyURL, err := resolveSpotifyURLForYoinkify(req) + if err != nil { + return DeezerDownloadResult{}, err + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "track": req.TrackNumber, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "disc": req.DiscNumber, + }) + + var outputPath string + if isSafOutput { + outputPath = strings.TrimSpace(req.OutputPath) + if outputPath == "" && isFDOutput(req.OutputFD) { + outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) + } + } else { + filename = sanitizeFilename(filename) + ".flac" + outputPath = filepath.Join(req.OutputDir, filename) + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + } + } + + var parallelResult *ParallelDownloadResult + parallelDone := make(chan struct{}) + go func() { + defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } + parallelResult = FetchCoverAndLyricsParallel( + coverURL, + req.EmbedMaxQualityCover, + req.SpotifyID, + req.TrackName, + req.ArtistName, + embedLyrics, + int64(req.DurationMS), + ) + }() + + if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err) + } + + <-parallelDone + + if req.ItemID != "" { + SetItemProgress(req.ItemID, 1.0, 0, 0) + SetItemFinalizing(req.ItemID) + } + + metadata := Metadata{ + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, + } + + var coverData []byte + if parallelResult != nil && parallelResult.CoverData != nil { + coverData = parallelResult.CoverData + } + + if isSafOutput || !req.EmbedMetadata { + if !req.EmbedMetadata { + GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") + } else { + GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } + } else { + if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { + GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err) + } + + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" + } + + if lyricsMode == "external" || lyricsMode == "both" { + if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Deezer] LRC file saved: %s\n", lrcPath) + } + } + + if lyricsMode == "embed" || lyricsMode == "both" { + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr) + } + } + } + } + + if !isSafOutput { + AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + } + + bitDepth, sampleRate := 0, 0 + if quality, qErr := GetAudioQuality(outputPath); qErr == nil { + bitDepth = quality.BitDepth + sampleRate = quality.SampleRate + } + + lyricsLRC := "" + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsLRC = parallelResult.LyricsLRC + } + + return DeezerDownloadResult{ + FilePath: outputPath, + BitDepth: bitDepth, + SampleRate: sampleRate, + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + ReleaseDate: req.ReleaseDate, + TrackNumber: req.TrackNumber, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + LyricsLRC: lyricsLRC, + }, nil +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 6f2412e4..3c05a0fd 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -123,6 +123,35 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) return string(jsonBytes), nil } +func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } + + normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:")) + if normalizedArtistID == "" { + return "", fmt.Errorf("invalid Spotify artist ID") + } + + artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit) + if err != nil { + return "", err + } + + resp := map[string]interface{}{ + "artists": artists, + } + jsonBytes, err := json.Marshal(resp) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + func CheckAvailability(spotifyID, isrc string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) @@ -159,6 +188,7 @@ type DownloadRequest struct { OutputExt string `json:"output_ext,omitempty"` FilenameFormat string `json:"filename_format"` Quality string `json:"quality"` + EmbedMetadata bool `json:"embed_metadata"` EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` TrackNumber int `json:"track_number"` @@ -467,6 +497,24 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = amazonErr + case "deezer": + deezerResult, deezerErr := downloadFromDeezer(req) + if deezerErr == nil { + result = DownloadResult{ + FilePath: deezerResult.FilePath, + BitDepth: deezerResult.BitDepth, + SampleRate: deezerResult.SampleRate, + Title: deezerResult.Title, + Artist: deezerResult.Artist, + Album: deezerResult.Album, + ReleaseDate: deezerResult.ReleaseDate, + TrackNumber: deezerResult.TrackNumber, + DiscNumber: deezerResult.DiscNumber, + ISRC: deezerResult.ISRC, + LyricsLRC: deezerResult.LyricsLRC, + } + } + err = deezerErr case "youtube": youtubeResult, youtubeErr := downloadFromYouTube(req) if youtubeErr == nil { @@ -592,7 +640,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { enrichRequestExtendedMetadata(&req) - allServices := []string{"tidal", "qobuz", "amazon"} + allServices := []string{"tidal", "qobuz", "amazon", "deezer"} preferredService := req.Service if preferredService == "" { preferredService = "tidal" @@ -680,6 +728,26 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) } err = amazonErr + case "deezer": + deezerResult, deezerErr := downloadFromDeezer(req) + if deezerErr == nil { + result = DownloadResult{ + FilePath: deezerResult.FilePath, + BitDepth: deezerResult.BitDepth, + SampleRate: deezerResult.SampleRate, + Title: deezerResult.Title, + Artist: deezerResult.Artist, + Album: deezerResult.Album, + ReleaseDate: deezerResult.ReleaseDate, + TrackNumber: deezerResult.TrackNumber, + DiscNumber: deezerResult.DiscNumber, + ISRC: deezerResult.ISRC, + LyricsLRC: deezerResult.LyricsLRC, + } + } else if !errors.Is(deezerErr, ErrDownloadCancelled) { + GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr) + } + err = deezerErr } if err != nil && errors.Is(err, ErrDownloadCancelled) { @@ -1162,6 +1230,26 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) ( return string(jsonBytes), nil } +func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + client := GetDeezerClient() + artists, err := client.GetRelatedArtists(ctx, artistID, limit) + if err != nil { + return "", err + } + + resp := map[string]interface{}{ + "artists": artists, + } + jsonBytes, err := json.Marshal(resp) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + func GetDeezerMetadata(resourceType, resourceID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -3145,7 +3233,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du return "", fmt.Errorf("extension '%s' is disabled", extensionID) } - provider := NewExtensionProviderWrapper(ext) + // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu + // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). + ext.VMMu.Lock() + defer ext.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -3156,7 +3247,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du })() `, functionName, functionName) - result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout) + result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout) if err != nil { return "", fmt.Errorf("%s failed: %w", functionName, err) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 1b612691..b9b460c2 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -48,11 +48,12 @@ type LoadedExtension struct { Manifest *ExtensionManifest `json:"manifest"` VM *goja.Runtime `json:"-"` VMMu sync.Mutex `json:"-"` - Enabled bool `json:"enabled"` - Error string `json:"error,omitempty"` - DataDir string `json:"data_dir"` - SourceDir string `json:"source_dir"` - IconPath string `json:"icon_path"` + runtime *ExtensionRuntime + Enabled bool `json:"enabled"` + Error string `json:"error,omitempty"` + DataDir string `json:"data_dir"` + SourceDir string `json:"source_dir"` + IconPath string `json:"icon_path"` } type ExtensionManager struct { @@ -243,6 +244,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { } runtime := NewExtensionRuntime(ext) + ext.runtime = runtime runtime.RegisterAPIs(vm) runtime.RegisterGoBackendAPIs(vm) @@ -295,6 +297,13 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error { GoLog("[Extension] Cleanup called for %s\n", extensionID) } } + if ext.runtime != nil { + if err := ext.runtime.flushStorageNow(); err != nil { + GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err) + } + ext.runtime.closeStorageFlusher() + ext.runtime = nil + } delete(m.extensions, extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID) @@ -536,7 +545,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, extDir := existing.SourceDir wasEnabled := existing.Enabled - m.CleanupExtension(existing.ID) m.UnloadExtension(existing.ID) if extDir != "" { @@ -909,7 +917,6 @@ func (m *ExtensionManager) UnloadAllExtensions() { m.mu.Unlock() for _, id := range extensionIDs { - m.CleanupExtension(id) m.UnloadExtension(id) } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 694354b3..ceac1af4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -631,7 +631,7 @@ func GetProviderPriority() []string { defer providerPriorityMu.RUnlock() if len(providerPriority) == 0 { - return []string{"tidal", "qobuz", "amazon"} + return []string{"tidal", "qobuz", "amazon", "deezer"} } result := make([]string, len(providerPriority)) @@ -815,7 +815,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Copyright: req.Copyright, } - if req.Genre != "" || req.Label != "" { + if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { @@ -1013,7 +1013,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Copyright: req.Copyright, } - if req.Genre != "" || req.Label != "" { + if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { @@ -1147,6 +1147,24 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon } } err = amazonErr + case "deezer": + deezerResult, deezerErr := downloadFromDeezer(req) + if deezerErr == nil { + result = DownloadResult{ + FilePath: deezerResult.FilePath, + BitDepth: deezerResult.BitDepth, + SampleRate: deezerResult.SampleRate, + Title: deezerResult.Title, + Artist: deezerResult.Artist, + Album: deezerResult.Album, + ReleaseDate: deezerResult.ReleaseDate, + TrackNumber: deezerResult.TrackNumber, + DiscNumber: deezerResult.DiscNumber, + ISRC: deezerResult.ISRC, + LyricsLRC: deezerResult.LyricsLRC, + } + } + err = deezerErr default: return nil, fmt.Errorf("unknown built-in provider: %s", providerID) } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index ad2610c0..c45b52c1 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -88,18 +88,48 @@ type ExtensionRuntime struct { cookieJar http.CookieJar dataDir string vm *goja.Runtime + + storageMu sync.RWMutex + storageCache map[string]interface{} + storageLoaded bool + storageDirty bool + storageClosed bool + storageTimer *time.Timer + storageWriteMu sync.Mutex + + credentialsMu sync.RWMutex + credentialsCache map[string]interface{} + credentialsLoaded bool + storageFlushDelay time.Duration } +type privateIPCacheEntry struct { + isPrivate bool + expiresAt time.Time +} + +const ( + privateIPCacheTTL = 5 * time.Minute + privateIPErrorCacheTTL = 30 * time.Second + maxPrivateIPCacheSize = 1024 +) + +var ( + privateIPCache = make(map[string]privateIPCacheEntry) + privateIPCacheMu sync.RWMutex +) + func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { jar, _ := newSimpleCookieJar() runtime := &ExtensionRuntime{ - extensionID: ext.ID, - manifest: ext.Manifest, - settings: make(map[string]interface{}), - cookieJar: jar, - dataDir: ext.DataDir, - vm: ext.VM, + extensionID: ext.ID, + manifest: ext.Manifest, + settings: make(map[string]interface{}), + cookieJar: jar, + dataDir: ext.DataDir, + vm: ext.VM, + storageFlushDelay: defaultStorageFlushDelay, } // Extension sandbox enforces HTTPS-only domains. Do not apply global @@ -166,18 +196,68 @@ func isPrivateIP(host string) bool { return isPrivateIPAddr(ip) } + if cached, ok := getPrivateIPCache(hostLower); ok { + return cached + } + ips, err := net.LookupIP(hostLower) if err != nil { + setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL) return false } + isPrivate := false for _, ip := range ips { if isPrivateIPAddr(ip) { - return true + isPrivate = true + break } } - return false + setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL) + return isPrivate +} + +func getPrivateIPCache(host string) (bool, bool) { + now := time.Now() + + privateIPCacheMu.RLock() + entry, exists := privateIPCache[host] + privateIPCacheMu.RUnlock() + if !exists { + return false, false + } + + if now.Before(entry.expiresAt) { + return entry.isPrivate, true + } + + privateIPCacheMu.Lock() + delete(privateIPCache, host) + privateIPCacheMu.Unlock() + return false, false +} + +func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) { + expiresAt := time.Now().Add(ttl) + + privateIPCacheMu.Lock() + if len(privateIPCache) >= maxPrivateIPCacheSize { + now := time.Now() + for key, entry := range privateIPCache { + if now.After(entry.expiresAt) { + delete(privateIPCache, key) + } + } + if len(privateIPCache) >= maxPrivateIPCacheSize { + privateIPCache = make(map[string]privateIPCacheEntry) + } + } + privateIPCache[host] = privateIPCacheEntry{ + isPrivate: isPrivate, + expiresAt: expiresAt, + } + privateIPCacheMu.Unlock() } func isPrivateIPAddr(ip net.IP) bool { diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index ccd51308..9bb1191e 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -396,13 +396,14 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - data, err := os.ReadFile(fullSrc) + srcFile, err := os.Open(fullSrc) if err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, "error": fmt.Sprintf("failed to read source: %v", err), }) } + defer srcFile.Close() dir := filepath.Dir(fullDst) if err := os.MkdirAll(dir, 0755); err != nil { @@ -412,10 +413,26 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - if err := os.WriteFile(fullDst, data, 0644); err != nil { + dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, - "error": fmt.Sprintf("failed to write destination: %v", err), + "error": fmt.Sprintf("failed to open destination: %v", err), + }) + } + + if _, err := io.Copy(dstFile, srcFile); err != nil { + _ = dstFile.Close() + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to copy file: %v", err), + }) + } + + if err := dstFile.Close(); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to finalize destination: %v", err), }) } diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index 2e85033f..06cbdd33 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -11,42 +11,164 @@ import ( "io" "os" "path/filepath" + "reflect" + "time" "github.com/dop251/goja" ) // ==================== Storage API ==================== +const ( + defaultStorageFlushDelay = 400 * time.Millisecond + storageFlushRetryDelay = 2 * time.Second +) + func (r *ExtensionRuntime) getStoragePath() string { return filepath.Join(r.dataDir, "storage.json") } -func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { +func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return make(map[string]interface{}) + } + dst := make(map[string]interface{}, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func (r *ExtensionRuntime) ensureStorageLoaded() error { + r.storageMu.RLock() + if r.storageLoaded { + r.storageMu.RUnlock() + return nil + } + r.storageMu.RUnlock() + + r.storageMu.Lock() + defer r.storageMu.Unlock() + if r.storageLoaded { + return nil + } + storagePath := r.getStoragePath() data, err := os.ReadFile(storagePath) if err != nil { if os.IsNotExist(err) { - return make(map[string]interface{}), nil + r.storageCache = make(map[string]interface{}) + r.storageLoaded = true + return nil } - return nil, err + return err } var storage map[string]interface{} if err := json.Unmarshal(data, &storage); err != nil { + return err + } + if storage == nil { + storage = make(map[string]interface{}) + } + + r.storageCache = storage + r.storageLoaded = true + return nil +} + +func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { + if err := r.ensureStorageLoaded(); err != nil { return nil, err } - return storage, nil + r.storageMu.RLock() + defer r.storageMu.RUnlock() + return cloneInterfaceMap(r.storageCache), nil } -func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { - storagePath := r.getStoragePath() - data, err := json.MarshalIndent(storage, "", " ") +func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) { + if r.storageClosed { + return + } + if r.storageTimer != nil { + return + } + r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync) +} + +func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error { + data, err := json.Marshal(storage) if err != nil { return err } - return os.WriteFile(storagePath, data, 0600) + r.storageWriteMu.Lock() + defer r.storageWriteMu.Unlock() + + return os.WriteFile(r.getStoragePath(), data, 0600) +} + +func (r *ExtensionRuntime) flushStorageDirtyAsync() { + if err := r.flushStorageDirty(); err != nil { + GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err) + } +} + +func (r *ExtensionRuntime) flushStorageDirty() error { + r.storageMu.Lock() + if r.storageClosed { + r.storageTimer = nil + r.storageMu.Unlock() + return nil + } + if !r.storageDirty { + r.storageTimer = nil + r.storageMu.Unlock() + return nil + } + snapshot := cloneInterfaceMap(r.storageCache) + r.storageDirty = false + r.storageTimer = nil + r.storageMu.Unlock() + + if err := r.persistStorageSnapshot(snapshot); err != nil { + r.storageMu.Lock() + r.storageDirty = true + r.queueStorageFlushLocked(storageFlushRetryDelay) + r.storageMu.Unlock() + return err + } + + return nil +} + +func (r *ExtensionRuntime) flushStorageNow() error { + r.storageMu.Lock() + if r.storageTimer != nil { + r.storageTimer.Stop() + r.storageTimer = nil + } + if !r.storageLoaded || r.storageClosed { + r.storageMu.Unlock() + return nil + } + snapshot := cloneInterfaceMap(r.storageCache) + r.storageDirty = false + r.storageMu.Unlock() + + return r.persistStorageSnapshot(snapshot) +} + +func (r *ExtensionRuntime) closeStorageFlusher() { + r.storageMu.Lock() + r.storageClosed = true + r.storageDirty = false + if r.storageTimer != nil { + r.storageTimer.Stop() + r.storageTimer = nil + } + r.storageMu.Unlock() } func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { @@ -56,13 +178,14 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - storage, err := r.loadStorage() - if err != nil { + if err := r.ensureStorageLoaded(); err != nil { GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) return goja.Undefined() } - value, exists := storage[key] + r.storageMu.RLock() + value, exists := r.storageCache[key] + r.storageMu.RUnlock() if !exists { if len(call.Arguments) > 1 { return call.Arguments[1] @@ -81,18 +204,26 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() value := call.Arguments[1].Export() - storage, err := r.loadStorage() - if err != nil { + if err := r.ensureStorageLoaded(); err != nil { GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } - storage[key] = value - - if err := r.saveStorage(storage); err != nil { - GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + r.storageMu.Lock() + if r.storageClosed { + r.storageMu.Unlock() return r.vm.ToValue(false) } + if existing, exists := r.storageCache[key]; exists { + if reflect.DeepEqual(existing, value) { + r.storageMu.Unlock() + return r.vm.ToValue(true) + } + } + r.storageCache[key] = value + r.storageDirty = true + r.queueStorageFlushLocked(r.storageFlushDelay) + r.storageMu.Unlock() return r.vm.ToValue(true) } @@ -104,18 +235,24 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - storage, err := r.loadStorage() - if err != nil { + if err := r.ensureStorageLoaded(); err != nil { GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } - delete(storage, key) - - if err := r.saveStorage(storage); err != nil { - GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + r.storageMu.Lock() + if r.storageClosed { + r.storageMu.Unlock() return r.vm.ToValue(false) } + if _, exists := r.storageCache[key]; !exists { + r.storageMu.Unlock() + return r.vm.ToValue(true) + } + delete(r.storageCache, key) + r.storageDirty = true + r.queueStorageFlushLocked(r.storageFlushDelay) + r.storageMu.Unlock() return r.vm.ToValue(true) } @@ -159,31 +296,61 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { return hash[:], nil } -func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { +func (r *ExtensionRuntime) ensureCredentialsLoaded() error { + r.credentialsMu.RLock() + if r.credentialsLoaded { + r.credentialsMu.RUnlock() + return nil + } + r.credentialsMu.RUnlock() + + r.credentialsMu.Lock() + defer r.credentialsMu.Unlock() + if r.credentialsLoaded { + return nil + } + credPath := r.getCredentialsPath() data, err := os.ReadFile(credPath) if err != nil { if os.IsNotExist(err) { - return make(map[string]interface{}), nil + r.credentialsCache = make(map[string]interface{}) + r.credentialsLoaded = true + return nil } - return nil, err + return err } key, err := r.getEncryptionKey() if err != nil { - return nil, fmt.Errorf("failed to get encryption key: %w", err) + return fmt.Errorf("failed to get encryption key: %w", err) } decrypted, err := decryptAES(data, key) if err != nil { - return nil, fmt.Errorf("failed to decrypt credentials: %w", err) + return fmt.Errorf("failed to decrypt credentials: %w", err) } var creds map[string]interface{} if err := json.Unmarshal(decrypted, &creds); err != nil { + return err + } + if creds == nil { + creds = make(map[string]interface{}) + } + + r.credentialsCache = creds + r.credentialsLoaded = true + return nil +} + +func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { + if err := r.ensureCredentialsLoaded(); err != nil { return nil, err } - return creds, nil + r.credentialsMu.RLock() + defer r.credentialsMu.RUnlock() + return cloneInterfaceMap(r.credentialsCache), nil } func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { @@ -202,7 +369,15 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { } credPath := r.getCredentialsPath() - return os.WriteFile(credPath, encrypted, 0600) + if err := os.WriteFile(credPath, encrypted, 0600); err != nil { + return err + } + + r.credentialsMu.Lock() + r.credentialsCache = cloneInterfaceMap(creds) + r.credentialsLoaded = true + r.credentialsMu.Unlock() + return nil } func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { @@ -216,8 +391,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() value := call.Arguments[1].Export() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -225,9 +399,12 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { }) } - creds[key] = value + r.credentialsMu.RLock() + nextCreds := cloneInterfaceMap(r.credentialsCache) + r.credentialsMu.RUnlock() + nextCreds[key] = value - if err := r.saveCredentials(creds); err != nil { + if err := r.saveCredentials(nextCreds); err != nil { GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -247,13 +424,14 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) return goja.Undefined() } - value, exists := creds[key] + r.credentialsMu.RLock() + value, exists := r.credentialsCache[key] + r.credentialsMu.RUnlock() if !exists { if len(call.Arguments) > 1 { return call.Arguments[1] @@ -271,15 +449,17 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value key := call.Arguments[0].String() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } - delete(creds, key) + r.credentialsMu.RLock() + nextCreds := cloneInterfaceMap(r.credentialsCache) + r.credentialsMu.RUnlock() + delete(nextCreds, key) - if err := r.saveCredentials(creds); err != nil { + if err := r.saveCredentials(nextCreds); err != nil { GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } @@ -294,12 +474,13 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { return r.vm.ToValue(false) } - _, exists := creds[key] + r.credentialsMu.RLock() + _, exists := r.credentialsCache[key] + r.credentialsMu.RUnlock() return r.vm.ToValue(exists) } diff --git a/go_backend/extension_runtime_storage_test.go b/go_backend/extension_runtime_storage_test.go new file mode 100644 index 00000000..dad6781b --- /dev/null +++ b/go_backend/extension_runtime_storage_test.go @@ -0,0 +1,120 @@ +package gobackend + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/dop251/goja" +) + +func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) { + t.Helper() + result := runtime.storageSet(goja.FunctionCall{ + Arguments: []goja.Value{ + runtime.vm.ToValue(key), + runtime.vm.ToValue(value), + }, + }) + if !result.ToBoolean() { + t.Fatalf("storage.set(%q) returned false", key) + } +} + +func readStorageMap(t *testing.T, storagePath string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(storagePath) + if err != nil { + t.Fatalf("failed to read storage file: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal storage file: %v", err) + } + return parsed +} + +func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) { + ext := &LoadedExtension{ + ID: "storage-test", + Manifest: &ExtensionManifest{ + Name: "storage-test", + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + runtime.storageFlushDelay = 25 * time.Millisecond + runtime.RegisterAPIs(goja.New()) + + setStorageValue(t, runtime, "k1", "v1") + setStorageValue(t, runtime, "k2", 2) + + storagePath := filepath.Join(ext.DataDir, "storage.json") + deadline := time.Now().Add(1500 * time.Millisecond) + + var raw []byte + for time.Now().Before(deadline) { + data, err := os.ReadFile(storagePath) + if err == nil { + raw = data + break + } + time.Sleep(20 * time.Millisecond) + } + if len(raw) == 0 { + t.Fatalf("storage.json was not written within timeout") + } + + var parsed map[string]interface{} + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("failed to unmarshal storage file: %v", err) + } + if parsed["k1"] != "v1" { + t.Fatalf("expected k1=v1, got %v", parsed["k1"]) + } + if parsed["k2"] != float64(2) { + t.Fatalf("expected k2=2, got %v", parsed["k2"]) + } + if bytes.Contains(raw, []byte("\n")) { + t.Fatalf("expected compact JSON without indentation, got: %q", string(raw)) + } +} + +func TestUnloadExtension_FlushesPendingStorage(t *testing.T) { + ext := &LoadedExtension{ + ID: "unload-storage-test", + Manifest: &ExtensionManifest{ + Name: "unload-storage-test", + }, + DataDir: t.TempDir(), + VM: goja.New(), + } + + runtime := NewExtensionRuntime(ext) + runtime.storageFlushDelay = time.Hour + runtime.RegisterAPIs(ext.VM) + ext.runtime = runtime + + manager := &ExtensionManager{ + extensions: map[string]*LoadedExtension{ + ext.ID: ext, + }, + } + + setStorageValue(t, runtime, "persist_on_unload", true) + + if err := manager.UnloadExtension(ext.ID); err != nil { + t.Fatalf("UnloadExtension failed: %v", err) + } + + storagePath := filepath.Join(ext.DataDir, "storage.json") + parsed := readStorageMap(t, storagePath) + if parsed["persist_on_unload"] != true { + t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"]) + } +} diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 005a2ca9..3ec16555 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -22,6 +22,7 @@ const ( // Lyrics provider names (used in settings and cascade ordering) const ( + LyricsProviderSpotifyAPI = "spotify_api" LyricsProviderLRCLIB = "lrclib" LyricsProviderNetease = "netease" LyricsProviderMusixmatch = "musixmatch" @@ -33,6 +34,7 @@ const ( // LRCLIB first (no proxy dependency), then the others. var DefaultLyricsProviders = []string{ LyricsProviderLRCLIB, + LyricsProviderSpotifyAPI, LyricsProviderMusixmatch, LyricsProviderNetease, LyricsProviderAppleMusic, @@ -45,6 +47,11 @@ var ( lyricsProviders []string // ordered list of enabled providers ) +var ( + spotifyLyricsRateLimitMu sync.RWMutex + spotifyLyricsRateLimitedTil time.Time +) + // LyricsFetchOptions controls optional provider-specific enhancements. type LyricsFetchOptions struct { IncludeTranslationNetease bool `json:"include_translation_netease"` @@ -78,6 +85,7 @@ func SetLyricsProviderOrder(providers []string) { // Validate provider names validNames := map[string]bool{ + LyricsProviderSpotifyAPI: true, LyricsProviderLRCLIB: true, LyricsProviderNetease: true, LyricsProviderMusixmatch: true, @@ -114,6 +122,7 @@ func GetLyricsProviderOrder() []string { // GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ + {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"}, {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"}, @@ -245,6 +254,18 @@ type LRCLibResponse struct { SyncedLyrics string `json:"syncedLyrics"` } +type SpotifyLyricsLine struct { + TimeTag string `json:"timeTag"` + Words string `json:"words"` +} + +type SpotifyLyricsAPIResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + SyncType string `json:"syncType"` + Lines []SpotifyLyricsLine `json:"lines"` +} + type LyricsLine struct { StartTimeMs int64 `json:"startTimeMs"` Words string `json:"words"` @@ -352,6 +373,172 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return c.parseLRCLibResponse(&results[0]), nil } +func parseSpotifyLyricsTimeTagToMs(tag string) int64 { + raw := strings.TrimSpace(tag) + raw = strings.TrimPrefix(raw, "[") + raw = strings.TrimSuffix(raw, "]") + if raw == "" { + return 0 + } + + if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { + return ms + } + + re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`) + matches := re.FindStringSubmatch(raw) + if len(matches) != 4 { + return 0 + } + + minutes, _ := strconv.ParseInt(matches[1], 10, 64) + seconds, _ := strconv.ParseInt(matches[2], 10, 64) + fraction := matches[3] + fractionInt, _ := strconv.ParseInt(fraction, 10, 64) + if len(fraction) == 2 { + fractionInt *= 10 + } else if len(fraction) == 1 { + fractionInt *= 100 + } + return minutes*60*1000 + seconds*1000 + fractionInt +} + +func getSpotifyLyricsRateLimitUntil() time.Time { + spotifyLyricsRateLimitMu.RLock() + defer spotifyLyricsRateLimitMu.RUnlock() + return spotifyLyricsRateLimitedTil +} + +func setSpotifyLyricsRateLimitUntil(until time.Time) { + spotifyLyricsRateLimitMu.Lock() + spotifyLyricsRateLimitedTil = until + spotifyLyricsRateLimitMu.Unlock() +} + +func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time { + raw := strings.TrimSpace(retryAfter) + if raw == "" { + return now.Add(10 * time.Minute) + } + + if sec, err := strconv.Atoi(raw); err == nil && sec > 0 { + return now.Add(time.Duration(sec) * time.Second) + } + + if when, err := http.ParseTime(raw); err == nil && when.After(now) { + return when + } + + return now.Add(10 * time.Minute) +} + +func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { + now := time.Now() + if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) { + waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds())) + return nil, fmt.Errorf( + "Spotify Lyrics API cooldown active (%ds remaining after previous 429)", + waitFor, + ) + } + + spotifyID = strings.TrimSpace(spotifyID) + if spotifyID == "" { + return nil, fmt.Errorf("spotify ID is empty") + } + if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" { + spotifyID = parsed.ID + } + + apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID)) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if resp.StatusCode == http.StatusTooManyRequests { + retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now) + setSpotifyLyricsRateLimitUntil(retryUntil) + } + var payload map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil { + if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { + return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) + } + if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" { + return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) + } + } + return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) + } + + var apiResp SpotifyLyricsAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) + } + + if apiResp.Error { + msg := strings.TrimSpace(apiResp.Message) + if msg == "" { + msg = "Spotify Lyrics API returned error" + } + return nil, fmt.Errorf("%s", msg) + } + + result := &LyricsResponse{ + Lines: make([]LyricsLine, 0, len(apiResp.Lines)), + SyncType: apiResp.SyncType, + Instrumental: false, + PlainLyrics: "", + Provider: "Spotify Lyrics API", + Source: "Spotify Lyrics API", + } + + for _, line := range apiResp.Lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) + result.Lines = append(result.Lines, LyricsLine{ + StartTimeMs: startMs, + Words: words, + EndTimeMs: 0, + }) + } + + if len(result.Lines) > 1 { + for i := 0; i < len(result.Lines)-1; i++ { + nextStart := result.Lines[i+1].StartTimeMs + if nextStart > result.Lines[i].StartTimeMs { + result.Lines[i].EndTimeMs = nextStart + } + } + last := len(result.Lines) - 1 + if result.Lines[last].EndTimeMs == 0 { + result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000 + } + } + + if len(result.Lines) == 0 { + return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") + } + + if result.SyncType == "" { + result.SyncType = "LINE_SYNCED" + } + + return result, nil +} + func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { var bestSynced *LRCLibResponse var bestPlain *LRCLibResponse @@ -448,6 +635,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st var err error switch providerName { + case LyricsProviderSpotifyAPI: + lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID) + case LyricsProviderLRCLIB: lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index f8f0fcbc..044e1315 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -3,7 +3,6 @@ package gobackend import ( "bufio" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -28,6 +27,11 @@ var ( qobuzDownloaderOnce sync.Once ) +const ( + qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" + qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" +) + type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -185,13 +189,19 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { } } - // Some tracks are symbol/emoji-heavy and providers can return textual - // aliases. If artist/duration already matched upstream, avoid false rejects. + // Emoji/symbol-only titles must be matched strictly to avoid false positives + // like mapping "🪐" to unrelated textual tracks. if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && strings.TrimSpace(expectedTitle) != "" && strings.TrimSpace(foundTitle) != "" { - GoLog("[Qobuz] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true + expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) + foundSymbols := normalizeSymbolOnlyTitle(foundTitle) + if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols { + GoLog("[Qobuz] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + GoLog("[Qobuz] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle) + return false } expectedLatin := qobuzIsLatinScript(expectedTitle) @@ -331,8 +341,7 @@ func NewQobuzDownloader() *QobuzDownloader { } func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") - trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID) + trackURL := fmt.Sprintf("%s%d&app_id=%s", qobuzTrackGetBaseURL, trackID, q.appID) req, err := http.NewRequest("GET", trackURL, nil) if err != nil { @@ -358,46 +367,10 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { } func (q *QobuzDownloader) GetAvailableAPIs() []string { - encodedAPIs := []string{ - "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", - "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", - "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", + return []string{ + "https://dab.yeet.su/api/stream?trackId=", + "https://dabmusic.xyz/api/stream?trackId=", } - - var apis []string - for _, encoded := range encodedAPIs { - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - continue - } - apis = append(apis, "https://"+string(decoded)) - } - - return apis -} - -func mapJumoQuality(quality string) int { - switch quality { - case "6": - return 6 - case "7": - return 7 - case "27": - return 27 - default: - return 6 - } -} - -func decodeXOR(data []byte) string { - text := string(data) - runes := []rune(text) - result := make([]rune, len(runes)) - for i, char := range runes { - key := rune((i * 17) % 128) - result[i] = char ^ 253 ^ key - } - return string(result) } func extractQobuzDownloadURLFromBody(body []byte) (string, error) { @@ -436,67 +409,8 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) { return "", fmt.Errorf("no download URL in response") } -func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { - formatID := mapJumoQuality(quality) - region := "US" - jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) - - GoLog("[Qobuz] Trying Jumo API fallback...\n") - - client := NewHTTPClientWithTimeout(30 * time.Second) - req, err := http.NewRequest("GET", jumoURL, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", getRandomUserAgent()) - req.Header.Set("Referer", "https://jumo-dl.pages.dev/") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var result map[string]any - if err := json.Unmarshal(body, &result); err != nil { - decoded := decodeXOR(body) - if err := json.Unmarshal([]byte(decoded), &result); err != nil { - return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err) - } - } - - if urlVal, ok := result["url"].(string); ok && urlVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully\n") - return urlVal, nil - } - - if data, ok := result["data"].(map[string]any); ok { - if urlVal, ok := data["url"].(string); ok && urlVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n") - return urlVal, nil - } - } - - if linkVal, ok := result["link"].(string); ok && linkVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n") - return linkVal, nil - } - - return "", fmt.Errorf("URL not found in Jumo response") -} - func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { @@ -538,8 +452,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { @@ -621,8 +534,6 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* } func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - queries := []string{} if artistName != "" && trackName != "" { @@ -674,7 +585,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam GoLog("[Qobuz] Searching for: %s\n", cleanQuery) - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID) + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { @@ -799,26 +710,8 @@ func getQobuzAPITimeout() time.Duration { return qobuzAPITimeoutMobile } -// qobuzSquidCountries defines the region fallback order for squid.wtf API -var qobuzSquidCountries = []string{"US", "FR"} - // fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic -// For squid.wtf APIs, it tries US region first, then falls back to FR func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) { - isSquid := strings.Contains(api, "squid.wtf") - - if isSquid { - for _, country := range qobuzSquidCountries { - GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country) - result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country) - if err == nil { - return result, nil - } - GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err) - } - return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)") - } - return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "") } @@ -964,34 +857,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("no Qobuz API available") } - _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) + qualityCode := strings.TrimSpace(quality) + if qualityCode == "" || qualityCode == "5" { + qualityCode = "6" + } + + downloadFunc := func(qual string) (string, error) { + _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, qual) + if err != nil { + return "", err + } + return downloadURL, nil + } + + downloadURL, err := downloadFunc(qualityCode) if err == nil { return downloadURL, nil } - GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n") - jumoURL, jumoErr := q.downloadFromJumo(trackID, quality) - if jumoErr == nil { - return jumoURL, nil - } - - if quality == "27" { + currentQuality := qualityCode + if currentQuality == "27" { GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") - jumoURL, jumoErr = q.downloadFromJumo(trackID, "7") - if jumoErr == nil { - return jumoURL, nil + downloadURL, err = downloadFunc("7") + if err == nil { + return downloadURL, nil } + currentQuality = "7" } - if quality == "27" || quality == "7" { + if currentQuality == "7" { GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n") - jumoURL, jumoErr = q.downloadFromJumo(trackID, "6") - if jumoErr == nil { - return jumoURL, nil + downloadURL, err = downloadFunc("6") + if err == nil { + return downloadURL, nil } } - return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) + return "", fmt.Errorf("all Qobuz APIs failed: %w", err) } func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { @@ -1087,14 +989,12 @@ type QobuzDownloadResult struct { LyricsLRC string } -func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { - downloader := NewQobuzDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } +func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) { + if downloader == nil { + downloader = NewQobuzDownloader() + } + if strings.TrimSpace(logPrefix) == "" { + logPrefix = "Qobuz" } expectedDurationSec := req.DurationMS / 1000 @@ -1104,15 +1004,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate) if req.QobuzID != "" { - GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID) + GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID) var trackID int64 if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { track, err = downloader.GetTrackByID(trackID) if err != nil { - GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err) + GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name) + GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) } } } @@ -1120,10 +1020,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 2: Use cached Qobuz Track ID (fast, no search needed) if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { - GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) + GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID) track, err = downloader.GetTrackByID(cached.QobuzTrackID) if err != nil { - GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err) + GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) track = nil } } @@ -1131,19 +1031,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID if track == nil && req.SpotifyID != "" && req.QobuzID == "" { - GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID) + GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID) songLinkClient := NewSongLinkClient() availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC) if slErr == nil && availability != nil && availability.QobuzID != "" { var trackID int64 if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID) + GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID) track, err = downloader.GetTrackByID(trackID) if err != nil { - GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err) + GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name) + GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) // Cache for future use if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) @@ -1155,16 +1055,16 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 4: ISRC search with duration verification if track == nil && req.ISRC != "" { - GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) + GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) if track != nil { if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, track.Performer.Name) + GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + logPrefix, req.ArtistName, track.Performer.Name) track = nil } else if !qobuzTitlesMatch(req.TrackName, track.Title) { - GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.TrackName, track.Title) + GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + logPrefix, req.TrackName, track.Title) track = nil } } @@ -1172,11 +1072,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds) if track == nil { - GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName) + GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, track.Performer.Name) + GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + logPrefix, req.ArtistName, track.Performer.Name) track = nil } } @@ -1186,14 +1086,32 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { if err != nil { errMsg = err.Error() } - return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) + return nil, fmt.Errorf("qobuz search failed: %s", errMsg) } - GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) + GoLog("[%s] Match found: '%s' by '%s' (duration: %ds)\n", logPrefix, track.Title, track.Performer.Name, track.Duration) if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) } + return track, nil +} + +func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { + downloader := NewQobuzDownloader() + + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } + } + + track, err := resolveQobuzTrackForRequest(req, downloader, "Qobuz") + if err != nil { + return QobuzDownloadResult{}, err + } + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, "artist": req.ArtistName, @@ -1241,13 +1159,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, + coverURL, req.EmbedMaxQualityCover, req.SpotifyID, req.TrackName, req.ArtistName, - req.EmbedLyrics, + embedLyrics, int64(req.DurationMS), ) }() @@ -1297,8 +1221,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } - if isSafOutput { - GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + if isSafOutput || !req.EmbedMetadata { + if !req.EmbedMetadata { + GoLog("[Qobuz] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") + } else { + GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } } else { if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) @@ -1337,7 +1265,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } lyricsLRC := "" - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 43cca7aa..975573df 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -561,16 +561,17 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin availability.DeezerURL = deezerLink.URL } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they are usually closer to music catalog matches. + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } @@ -658,16 +659,17 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they are usually closer to music catalog matches. + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } @@ -805,16 +807,17 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila availability.DeezerURL = deezerLink.URL availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they are usually closer to music catalog matches. + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 6501f952..9728a72f 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -16,13 +16,14 @@ import ( ) const ( - spotifyTokenURL = "https://accounts.spotify.com/api/token" - playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" - albumBaseURL = "https://api.spotify.com/v1/albums/%s" - trackBaseURL = "https://api.spotify.com/v1/tracks/%s" - artistBaseURL = "https://api.spotify.com/v1/artists/%s" - artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" - searchBaseURL = "https://api.spotify.com/v1/search" + spotifyTokenURL = "https://accounts.spotify.com/api/token" + playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" + albumBaseURL = "https://api.spotify.com/v1/albums/%s" + trackBaseURL = "https://api.spotify.com/v1/tracks/%s" + artistBaseURL = "https://api.spotify.com/v1/artists/%s" + artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" + artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists" + searchBaseURL = "https://api.spotify.com/v1/search" artistCacheTTL = 10 * time.Minute searchCacheTTL = 5 * time.Minute @@ -140,6 +141,8 @@ type TrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` + AlbumID string `json:"album_id,omitempty"` + ArtistID string `json:"artist_id,omitempty"` AlbumType string `json:"album_type,omitempty"` } @@ -361,6 +364,10 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, } for _, track := range response.Tracks.Items { + var firstArtistID string + if len(track.Artists) > 0 { + firstArtistID = track.Artists[0].ID + } result.Tracks = append(result.Tracks, TrackMetadata{ SpotifyID: track.ID, Artists: joinArtists(track.Artists), @@ -375,6 +382,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumID: track.Album.ID, + ArtistID: firstArtistID, AlbumType: track.Album.AlbumType, }) } @@ -426,6 +435,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra } for _, track := range response.Tracks.Items { + var firstArtistID string + if len(track.Artists) > 0 { + firstArtistID = track.Artists[0].ID + } result.Tracks = append(result.Tracks, TrackMetadata{ SpotifyID: track.ID, Artists: joinArtists(track.Artists), @@ -440,6 +453,8 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumID: track.Album.ID, + ArtistID: firstArtistID, AlbumType: track.Album.AlbumType, }) } @@ -838,6 +853,47 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token return result, nil } +func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + var data struct { + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + Images []image `json:"images"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Popularity int `json:"popularity"` + } `json:"artists"` + } + + if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil { + return nil, err + } + + maxItems := len(data.Artists) + if limit > 0 && limit < maxItems { + maxItems = limit + } + + result := make([]SearchArtistResult, 0, maxItems) + for i := 0; i < maxItems; i++ { + artist := data.Artists[i] + result = append(result, SearchArtistResult{ + ID: artist.ID, + Name: artist.Name, + Images: firstImageURL(artist.Images), + Followers: artist.Followers.Total, + Popularity: artist.Popularity, + }) + } + return result, nil +} + func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string { var data struct { ExternalID externalID `json:"external_ids"` diff --git a/go_backend/tidal.go b/go_backend/tidal.go index bc0ca7ba..22fd2377 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -20,13 +20,8 @@ import ( ) type TidalDownloader struct { - client *http.Client - clientID string - clientSecret string - apiURL string - cachedToken string - tokenExpiresAt time.Time - tokenMu sync.Mutex + client *http.Client + apiURL string } var ( @@ -34,6 +29,11 @@ var ( tidalDownloaderOnce sync.Once ) +const ( + spotifyTrackBaseURL = "https://open.spotify.com/track/" + songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url=" +) + type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -102,13 +102,8 @@ type MPD struct { func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { - clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") - clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") - globalTidalDownloader = &TidalDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout - clientID: string(clientID), - clientSecret: string(clientSecret), + client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout } apis := globalTidalDownloader.GetAvailableAPIs() @@ -120,85 +115,27 @@ func NewTidalDownloader() *TidalDownloader { } func (t *TidalDownloader) GetAvailableAPIs() []string { - encodedAPIs := []string{ - "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority) - "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf - "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site - "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site - "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site - "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site - "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site - "aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net - "aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net + return []string{ + "https://tidal-api.binimum.org", // priority + "https://tidal.kinoplus.online", + "https://triton.squid.wtf", + "https://vogel.qqdl.site", + "https://maus.qqdl.site", + "https://hund.qqdl.site", + "https://katze.qqdl.site", + "https://wolf.qqdl.site", + "https://hifi-one.spotisaver.net", + "https://hifi-two.spotisaver.net", } - - var apis []string - for _, encoded := range encodedAPIs { - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - continue - } - apis = append(apis, "https://"+string(decoded)) - } - - return apis } func (t *TidalDownloader) GetAccessToken() (string, error) { - t.tokenMu.Lock() - defer t.tokenMu.Unlock() - - if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) { - return t.cachedToken, nil - } - - data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) - - authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") - req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data)) - if err != nil { - return "", err - } - - req.SetBasicAuth(t.clientID, t.clientSecret) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode) - } - - var result struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } - - t.cachedToken = result.AccessToken - if result.ExpiresIn > 0 { - t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) - } else { - t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min - } - - return result.AccessToken, nil + return "", fmt.Errorf("tidal official metadata API disabled: no client credentials mode") } func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("%s%s", spotifyTrackBaseURL, spotifyTrackID) + apiURL := fmt.Sprintf("%s%s", songLinkLookupBaseURL, url.QueryEscape(spotifyURL)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -251,321 +188,20 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { } func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) - } - - trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") - trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) - - req, err := http.NewRequest("GET", trackURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to get track info: HTTP %d", resp.StatusCode) - } - - var trackInfo TidalTrack - if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil { - return nil, err - } - - return &trackInfo, nil + return nil, fmt.Errorf("tidal track lookup API disabled: no client credentials mode") } func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, err - } - - searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&countryCode=US", string(searchBase), url.QueryEscape(isrc)) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Items []TidalTrack `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - for i := range result.Items { - if result.Items[i].ISRC == isrc { - return &result.Items[i], nil - } - } - - if len(result.Items) == 0 { - return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) - } - - return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) + return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode") } // Now includes romaji conversion for Japanese text (4 search strategies like PC) -func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, err - } - - // Build search queries - multiple strategies (same as PC version) - queries := []string{} - - if artistName != "" && trackName != "" { - queries = append(queries, artistName+" "+trackName) - } - - if trackName != "" { - queries = append(queries, trackName) - } - - if ContainsJapanese(trackName) || ContainsJapanese(artistName) { - romajiTrack := JapaneseToRomaji(trackName) - romajiArtist := JapaneseToRomaji(artistName) - - cleanRomajiTrack := CleanToASCII(romajiTrack) - cleanRomajiArtist := CleanToASCII(romajiArtist) - - if cleanRomajiArtist != "" && cleanRomajiTrack != "" { - romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack - if !containsQuery(queries, romajiQuery) { - queries = append(queries, romajiQuery) - GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery) - } - } - - if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { - if !containsQuery(queries, cleanRomajiTrack) { - queries = append(queries, cleanRomajiTrack) - } - } - - if artistName != "" && cleanRomajiTrack != "" { - partialQuery := artistName + " " + cleanRomajiTrack - if !containsQuery(queries, partialQuery) { - queries = append(queries, partialQuery) - } - } - } - - if artistName != "" { - artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) - if artistOnly != "" && !containsQuery(queries, artistOnly) { - queries = append(queries, artistOnly) - } - } - - searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") - - var allTracks []TidalTrack - searchedQueries := make(map[string]bool) - - for _, query := range queries { - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" || searchedQueries[cleanQuery] { - continue - } - searchedQueries[cleanQuery] = true - - GoLog("[Tidal] Searching for: %s\n", cleanQuery) - - searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery)) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - continue - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err) - continue - } - - if resp.StatusCode != 200 { - resp.Body.Close() - continue - } - - var result struct { - Items []TidalTrack `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - resp.Body.Close() - continue - } - resp.Body.Close() - - if len(result.Items) > 0 { - GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) - - if spotifyISRC != "" { - for i := range result.Items { - if result.Items[i].ISRC == spotifyISRC { - track := &result.Items[i] - if expectedDuration > 0 { - durationDiff := track.Duration - expectedDuration - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= 3 { - GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title) - return track, nil - } - GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", - expectedDuration, track.Duration) - } else { - GoLog("[Tidal] ISRC match: '%s'\n", track.Title) - return track, nil - } - } - } - } - - allTracks = append(allTracks, result.Items...) - } - } - - if len(allTracks) == 0 { - return nil, fmt.Errorf("no tracks found for any search query") - } - - if spotifyISRC != "" { - GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC) - var isrcMatches []*TidalTrack - for i := range allTracks { - track := &allTracks[i] - if track.ISRC == spotifyISRC { - isrcMatches = append(isrcMatches, track) - } - } - - if len(isrcMatches) > 0 { - if expectedDuration > 0 { - var durationVerifiedMatches []*TidalTrack - for _, track := range isrcMatches { - durationDiff := track.Duration - expectedDuration - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= 3 { - durationVerifiedMatches = append(durationVerifiedMatches, track) - } - } - - if len(durationVerifiedMatches) > 0 { - GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", - durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) - return durationVerifiedMatches[0], nil - } - - GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", - spotifyISRC, expectedDuration, isrcMatches[0].Duration) - return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", - expectedDuration, isrcMatches[0].Duration) - } - - GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) - return isrcMatches[0], nil - } - - GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC) - return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) - } - - if expectedDuration > 0 { - tolerance := 3 // 3 seconds tolerance - var durationMatches []*TidalTrack - - for i := range allTracks { - track := &allTracks[i] - durationDiff := track.Duration - expectedDuration - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= tolerance { - durationMatches = append(durationMatches, track) - } - } - - if len(durationMatches) > 0 { - bestMatch := durationMatches[0] - for _, track := range durationMatches { - for _, tag := range track.MediaMetadata.Tags { - if tag == "HIRES_LOSSLESS" { - bestMatch = track - break - } - } - } - GoLog("[Tidal] Found via duration match: %s - %s (%s)\n", - bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality) - return bestMatch, nil - } - } - - bestMatch := &allTracks[0] - for i := range allTracks { - track := &allTracks[i] - for _, tag := range track.MediaMetadata.Tags { - if tag == "HIRES_LOSSLESS" { - bestMatch = track - break - } - } - if bestMatch != &allTracks[0] { - break - } - } - - GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n", - bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality) - - return bestMatch, nil -} - -func containsQuery(queries []string, query string) bool { - for _, q := range queries { - if q == query { - return true - } - } - return false +func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { + return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { - return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) + return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } // TidalDownloadInfo contains download URL and quality info @@ -1300,13 +936,19 @@ func titlesMatch(expectedTitle, foundTitle string) bool { } } - // Some tracks are symbol/emoji-heavy and providers can return textual - // aliases. If artist/duration already matched upstream, avoid false rejects. + // Emoji/symbol-only titles must be matched strictly to avoid false positives + // like mapping "🪐" to "Higher Power". if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && strings.TrimSpace(expectedTitle) != "" && strings.TrimSpace(foundTitle) != "" { - GoLog("[Tidal] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true + expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) + foundSymbols := normalizeSymbolOnlyTitle(foundTitle) + if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols { + GoLog("[Tidal] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + GoLog("[Tidal] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle) + return false } expectedLatin := isLatinScript(expectedTitle) @@ -1426,182 +1068,9 @@ func isLatinScript(s string) bool { return true } -func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { - downloader := NewTidalDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - expectedDurationSec := req.DurationMS / 1000 - - var track *TidalTrack - var err error - - if req.TidalID != "" { - GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) - var trackID int64 - if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { - track, err = downloader.GetTrackInfoByID(trackID) - if err != nil { - GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err) - track = nil - } else if track != nil { - GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name) - } - } - } - - if track == nil && req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { - GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) - track, err = downloader.GetTrackInfoByID(cached.TidalTrackID) - if err != nil { - GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err) - track = nil // Fall through to normal search - } - } - } - - if track == nil && req.ISRC != "" { - GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) - track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) - if track != nil { - // Verify artist only (ISRC match is already accurate) - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - if !artistsMatch(req.ArtistName, tidalArtist) { - GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - } - } - - if track == nil && req.SpotifyID != "" { - GoLog("[Tidal] ISRC search failed, trying SongLink...\n") - - var trackID int64 - var gotTidalID bool - - if strings.HasPrefix(req.SpotifyID, "deezer:") { - deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") - GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID) - if slErr == nil && availability != nil && availability.TidalID != "" { - if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID) - gotTidalID = true - } - } - // Fallback to URL parsing if TidalID not in struct - if !gotTidalID && availability != nil && availability.TidalURL != "" { - var idErr error - trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) - if idErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID) - gotTidalID = true - } - } - } else { - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - if slErr == nil && availability != nil && availability.TidalID != "" { - if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID) - gotTidalID = true - } - } - // Fallback to URL parsing if TidalID not in struct - if !gotTidalID && availability != nil && availability.TidalURL != "" { - var idErr error - trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) - if idErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID) - gotTidalID = true - } - } - } - - if gotTidalID && trackID > 0 { - track, err = downloader.GetTrackInfoByID(trackID) - if track != nil { - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - - if !artistsMatch(req.ArtistName, tidalArtist) { - GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - - if track != nil && expectedDurationSec > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff > 3 { - GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", - expectedDurationSec, track.Duration) - track = nil // Reject this match - } - } - - // Cache for future use - if track != nil && req.ISRC != "" { - GetTrackIDCache().SetTidal(req.ISRC, track.ID) - } - } - } - } - +func tidalTrackArtistsDisplay(track *TidalTrack) string { if track == nil { - GoLog("[Tidal] Trying metadata search as last resort...\n") - track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) - if track != nil { - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - - if !titlesMatch(req.TrackName, track.Title) { - GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - req.TrackName, track.Title) - track = nil - } else if !artistsMatch(req.ArtistName, tidalArtist) { - GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - } - } - - if track == nil { - errMsg := "could not find matching track on Tidal (artist/duration mismatch)" - if err != nil { - errMsg = err.Error() - } - return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) + return "" } tidalArtist := track.Artist.Name @@ -1612,10 +1081,130 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } tidalArtist = strings.Join(artistNames, ", ") } - GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration) + return tidalArtist +} + +func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) { + if downloader == nil { + downloader = NewTidalDownloader() + } + if strings.TrimSpace(logPrefix) == "" { + logPrefix = "Tidal" + } + + expectedDurationSec := req.DurationMS / 1000 + var trackID int64 + var gotTidalID bool + + if req.TidalID != "" { + GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID) + if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + gotTidalID = true + } + } + + if !gotTidalID && req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { + GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.TidalTrackID) + trackID = cached.TidalTrackID + gotTidalID = true + } + } + + if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") { + GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix) + + resolveFromAvailability := func(availability *TrackAvailability) { + if availability == nil || gotTidalID { + return + } + if availability.TidalID != "" { + if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) + gotTidalID = true + return + } + } + if availability.TidalURL != "" { + var idErr error + trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) + if idErr == nil && trackID > 0 { + GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) + gotTidalID = true + } + } + } + + // Prefer Deezer-based SongLink lookup when DeezerID is available. + if req.DeezerID != "" { + GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID) + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckAvailabilityFromDeezer(req.DeezerID) + if slErr == nil { + resolveFromAvailability(availability) + } else { + GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr) + } + } + + if !gotTidalID && req.SpotifyID != "" { + if strings.HasPrefix(req.SpotifyID, "deezer:") { + deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") + GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID) + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID) + if slErr == nil { + resolveFromAvailability(availability) + } else { + GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr) + } + } + } + + if !gotTidalID && req.SpotifyID != "" && !strings.HasPrefix(req.SpotifyID, "deezer:") { + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + if slErr == nil { + resolveFromAvailability(availability) + } + } + } + + if !gotTidalID || trackID <= 0 { + return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") + } + + track := &TidalTrack{ + ID: trackID, + Title: strings.TrimSpace(req.TrackName), + ISRC: strings.TrimSpace(req.ISRC), + Duration: expectedDurationSec, + TrackNumber: req.TrackNumber, + VolumeNumber: req.DiscNumber, + } + track.Artist.Name = strings.TrimSpace(req.ArtistName) + track.Album.Title = strings.TrimSpace(req.AlbumName) + track.Album.ReleaseDate = strings.TrimSpace(req.ReleaseDate) if req.ISRC != "" { - GetTrackIDCache().SetTidal(req.ISRC, track.ID) + GetTrackIDCache().SetTidal(req.ISRC, trackID) + } + return track, nil +} + +func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { + downloader := NewTidalDownloader() + + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } + } + + track, err := resolveTidalTrackForRequest(req, downloader, "Tidal") + if err != nil { + return TidalDownloadResult{}, err } quality := req.Quality @@ -1694,13 +1283,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, + coverURL, req.EmbedMaxQualityCover, req.SpotifyID, req.TrackName, req.ArtistName, - req.EmbedLyrics, + embedLyrics, int64(req.DurationMS), ) }() @@ -1784,11 +1379,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) { - if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { - fmt.Printf("Warning: failed to embed metadata: %v\n", err) + if req.EmbedMetadata { + if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { + fmt.Printf("Warning: failed to embed metadata: %v\n", err) + } + } else { + GoLog("[Tidal] Metadata embedding disabled by settings, skipping FLAC metadata/lyrics embedding\n") } - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" @@ -1811,14 +1410,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] Lyrics embedded successfully") } } - } else if req.EmbedLyrics { + } else if req.EmbedMetadata && req.EmbedLyrics { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { if quality == "HIGH" { GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" @@ -1849,7 +1448,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { bitDepth = 0 sampleRate = 44100 } - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index a7bb186b..039ff434 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -41,3 +41,30 @@ func hasAlphaNumericRunes(value string) bool { } return false } + +// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters, +// digits, spaces and punctuation. This is useful for emoji-only titles such as +// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches. +func normalizeSymbolOnlyTitle(title string) string { + trimmed := strings.TrimSpace(strings.ToLower(title)) + if trimmed == "" { + return "" + } + + var b strings.Builder + b.Grow(len(trimmed)) + + for _, r := range trimmed { + switch { + case unicode.IsLetter(r), unicode.IsNumber(r), unicode.IsSpace(r), unicode.IsPunct(r): + continue + // Drop combining marks such as emoji variation selectors. + case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r): + continue + default: + b.WriteRune(r) + } + } + + return b.String() +} diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go index b9064c91..edc63058 100644 --- a/go_backend/title_match_utils_test.go +++ b/go_backend/title_match_utils_test.go @@ -27,8 +27,26 @@ func TestTitlesMatch_SeparatorVariants(t *testing.T) { } } +func TestTitlesMatch_EmojiStrict(t *testing.T) { + if titlesMatch("🪐", "Higher Power") { + t.Fatal("expected emoji title not to match unrelated textual title") + } + if !titlesMatch("🪐", "🪐") { + t.Fatal("expected identical emoji titles to match") + } +} + func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) { if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") { t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant") } } + +func TestQobuzTitlesMatch_EmojiStrict(t *testing.T) { + if qobuzTitlesMatch("🪐", "Higher Power") { + t.Fatal("expected emoji title not to match unrelated textual title") + } + if !qobuzTitlesMatch("🪐", "🪐") { + t.Fatal("expected identical emoji titles to match") + } +} diff --git a/go_backend/youtube.go b/go_backend/youtube.go index e5ff2c29..fdbadc63 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -276,11 +276,11 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr } // requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). -// Note: engine v2 currently serves MP3-oriented outputs, so we only use v2 for MP3 requests. +// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests. func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) { engines := []string{"v1"} if strings.EqualFold(audioFormat, "mp3") { - engines = append(engines, "v2") + engines = append(engines, "v3", "v2") } var lastErr error diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 2314d37f..8dfaa378 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -5,6 +5,15 @@ import Gobackend // Import Go framework @main @objc class AppDelegate: FlutterAppDelegate { private let CHANNEL = "com.zarz.spotiflac/backend" + private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream" + private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" + private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility) + private var downloadProgressTimer: DispatchSourceTimer? + private var downloadProgressEventSink: FlutterEventSink? + private var lastDownloadProgressPayload: String? + private var libraryScanProgressTimer: DispatchSourceTimer? + private var libraryScanProgressEventSink: FlutterEventSink? + private var lastLibraryScanProgressPayload: String? override func application( _ application: UIApplication, @@ -16,14 +25,111 @@ import Gobackend // Import Go framework name: CHANNEL, binaryMessenger: controller.binaryMessenger ) + let downloadProgressEvents = FlutterEventChannel( + name: DOWNLOAD_PROGRESS_STREAM_CHANNEL, + binaryMessenger: controller.binaryMessenger + ) + let libraryScanProgressEvents = FlutterEventChannel( + name: LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL, + binaryMessenger: controller.binaryMessenger + ) channel.setMethodCallHandler { [weak self] call, result in self?.handleMethodCall(call: call, result: result) } + downloadProgressEvents.setStreamHandler( + ClosureStreamHandler( + onListen: { [weak self] _, events in + self?.startDownloadProgressStream(events) + return nil + }, + onCancel: { [weak self] _ in + self?.stopDownloadProgressStream() + return nil + } + ) + ) + libraryScanProgressEvents.setStreamHandler( + ClosureStreamHandler( + onListen: { [weak self] _, events in + self?.startLibraryScanProgressStream(events) + return nil + }, + onCancel: { [weak self] _ in + self?.stopLibraryScanProgressStream() + return nil + } + ) + ) GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + deinit { + stopDownloadProgressStream() + stopLibraryScanProgressStream() + } + + private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) { + stopDownloadProgressStream() + downloadProgressEventSink = eventSink + lastDownloadProgressPayload = nil + + let timer = DispatchSource.makeTimerSource(queue: streamQueue) + timer.schedule(deadline: .now(), repeating: .milliseconds(800)) + timer.setEventHandler { [weak self] in + guard let self else { return } + let payload = GobackendGetAllDownloadProgress() as String? ?? "{}" + if payload == self.lastDownloadProgressPayload { + return + } + self.lastDownloadProgressPayload = payload + DispatchQueue.main.async { [weak self] in + self?.downloadProgressEventSink?(payload) + } + } + downloadProgressTimer = timer + timer.resume() + } + + private func stopDownloadProgressStream() { + downloadProgressTimer?.setEventHandler {} + downloadProgressTimer?.cancel() + downloadProgressTimer = nil + downloadProgressEventSink = nil + lastDownloadProgressPayload = nil + } + + private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) { + stopLibraryScanProgressStream() + libraryScanProgressEventSink = eventSink + lastLibraryScanProgressPayload = nil + + let timer = DispatchSource.makeTimerSource(queue: streamQueue) + timer.schedule(deadline: .now(), repeating: .milliseconds(800)) + timer.setEventHandler { [weak self] in + guard let self else { return } + let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}" + if payload == self.lastLibraryScanProgressPayload { + return + } + self.lastLibraryScanProgressPayload = payload + DispatchQueue.main.async { [weak self] in + self?.libraryScanProgressEventSink?(payload) + } + } + libraryScanProgressTimer = timer + timer.resume() + } + + private func stopLibraryScanProgressStream() { + libraryScanProgressTimer?.setEventHandler {} + libraryScanProgressTimer?.cancel() + libraryScanProgressTimer = nil + libraryScanProgressEventSink = nil + lastLibraryScanProgressPayload = nil + } private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { DispatchQueue.global(qos: .userInitiated).async { @@ -74,6 +180,14 @@ import Gobackend // Import Go framework let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error) if let error = error { throw error } return response + + case "getSpotifyRelatedArtists": + let args = call.arguments as! [String: Any] + let artistId = args["artist_id"] as! String + let limit = args["limit"] as? Int ?? 12 + let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error) + if let error = error { throw error } + return response case "checkAvailability": let args = call.arguments as! [String: Any] @@ -282,6 +396,14 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getDeezerRelatedArtists": + let args = call.arguments as! [String: Any] + let artistId = args["artist_id"] as! String + let limit = args["limit"] as? Int ?? 12 + let response = GobackendGetDeezerRelatedArtists(artistId, Int(limit), &error) + if let error = error { throw error } + return response + case "getDeezerMetadata": let args = call.arguments as! [String: Any] let resourceType = args["resource_type"] as! String @@ -840,3 +962,27 @@ import Gobackend // Import Go framework } } } + +private final class ClosureStreamHandler: NSObject, FlutterStreamHandler { + typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError? + typealias CancelHandler = (_ arguments: Any?) -> FlutterError? + + private let onListenHandler: ListenHandler + private let onCancelHandler: CancelHandler + + init( + onListen: @escaping ListenHandler, + onCancel: @escaping CancelHandler = { _ in nil } + ) { + self.onListenHandler = onListen + self.onCancelHandler = onCancel + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + onListenHandler(arguments, events) + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + onCancelHandler(arguments) + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4439fd20..5b1d9f87 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -105,5 +105,11 @@ tidal youtube-music + + + UIBackgroundModes + + audio + diff --git a/lib/app.dart b/lib/app.dart index 0e005e54..981c3bae 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -37,6 +37,9 @@ final _routerProvider = Provider((ref) { builder: (context, state) => const TutorialScreen(), ), ], + // Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches + // GoRouter, redirect to home instead of showing "Page Not Found". + errorBuilder: (context, state) => const MainShell(), ); }); @@ -54,10 +57,14 @@ class SpotiFLACApp extends ConsumerWidget { : null; Locale? locale; - if (localeString != 'system') { + if (localeString != 'system' && localeString.isNotEmpty) { if (localeString.contains('_')) { final parts = localeString.split('_'); - locale = Locale(parts[0], parts[1]); + if (parts.length == 2) { + locale = Locale(parts[0], parts[1]); + } else { + locale = Locale(parts[0]); + } } else { locale = Locale(localeString); } @@ -76,6 +83,25 @@ class SpotiFLACApp extends ConsumerWidget { themeAnimationCurve: Curves.easeInOut, routerConfig: router, locale: locale, + localeResolutionCallback: (deviceLocale, supportedLocales) { + if (locale != null) return locale; + if (deviceLocale == null) return supportedLocales.first; + + for (var supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode && + supportedLocale.countryCode == deviceLocale.countryCode) { + return supportedLocale; + } + } + + for (var supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode) { + return supportedLocale; + } + } + + return supportedLocales.first; + }, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index a8639cc8..121bd07e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.7.0'; - static const String buildNumber = '83'; + static const String version = '4.0.1'; + static const String buildNumber = '102'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8333786e..bccbf1fc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -544,6 +544,54 @@ abstract class AppLocalizations { /// **'Try other services if download fails'** String get optionsAutoFallbackSubtitle; + /// Toggle to skip to the next queue track when current track stream resolution fails + /// + /// In en, this message translates to: + /// **'Auto Skip Unavailable Tracks'** + String get optionsAutoSkipUnavailableTracks; + + /// Subtitle when auto skip on resolve failure is enabled + /// + /// In en, this message translates to: + /// **'Automatically skip to the next queue track when a stream cannot be resolved.'** + String get optionsAutoSkipUnavailableTracksSubtitleOn; + + /// Subtitle when auto skip on resolve failure is disabled + /// + /// In en, this message translates to: + /// **'Stop on failed track resolution and show an error.'** + String get optionsAutoSkipUnavailableTracksSubtitleOff; + + /// Tap behavior mode for track lists + /// + /// In en, this message translates to: + /// **'Interaction Mode'** + String get optionsInteractionMode; + + /// Interaction mode where taps queue downloads + /// + /// In en, this message translates to: + /// **'Downloader Mode'** + String get modeDownloader; + + /// Subtitle for downloader interaction mode + /// + /// In en, this message translates to: + /// **'Tap tracks to add them to download queue'** + String get modeDownloaderSubtitle; + + /// Interaction mode where taps start playback + /// + /// In en, this message translates to: + /// **'Streaming Mode'** + String get modeStreaming; + + /// Subtitle for streaming interaction mode + /// + /// In en, this message translates to: + /// **'Tap tracks to play instantly'** + String get modeStreamingSubtitle; + /// Enable extension download providers /// /// In en, this message translates to: @@ -1906,6 +1954,12 @@ abstract class AppLocalizations { /// **'No tracks found'** String get errorNoTracksFound; + /// Error - seek disabled for live decrypted stream + /// + /// In en, this message translates to: + /// **'Seeking is not supported for this live stream'** + String get errorSeekNotSupported; + /// Error - extension source not available /// /// In en, this message translates to: @@ -2842,6 +2896,12 @@ abstract class AppLocalizations { /// **'Download All ({count})'** String downloadAllCount(int count); + /// Play all button with count + /// + /// In en, this message translates to: + /// **'Play All ({count})'** + String playAllCount(int count); + /// Track count display /// /// In en, this message translates to: @@ -4048,12 +4108,24 @@ abstract class AppLocalizations { /// **'Download Discography'** String get discographyDownload; + /// Button - play artist discography + /// + /// In en, this message translates to: + /// **'Play Discography'** + String get discographyPlay; + /// Option - download entire discography /// /// In en, this message translates to: /// **'Download All'** String get discographyDownloadAll; + /// Option - play entire discography + /// + /// In en, this message translates to: + /// **'Play All'** + String get discographyPlayAll; + /// Subtitle showing total tracks and albums /// /// In en, this message translates to: @@ -4120,6 +4192,12 @@ abstract class AppLocalizations { /// **'Download Selected'** String get discographyDownloadSelected; + /// Button - play selected albums + /// + /// In en, this message translates to: + /// **'Play Selected'** + String get discographyPlaySelected; + /// Snackbar - tracks added from discography /// /// In en, this message translates to: @@ -5555,6 +5633,384 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Converted {success} of {total} tracks to {format}'** String selectionBatchConvertSuccess(int success, int total, String format); + + /// Title for mode selection step in setup wizard + /// + /// In en, this message translates to: + /// **'Choose Your Mode'** + String get setupModeSelectionTitle; + + /// Description for mode selection step + /// + /// In en, this message translates to: + /// **'How would you like to use SpotiFLAC? You can always change this later in Settings.'** + String get setupModeSelectionDescription; + + /// Title for downloader mode option + /// + /// In en, this message translates to: + /// **'Downloader'** + String get setupModeDownloaderTitle; + + /// Downloader mode feature 1 + /// + /// In en, this message translates to: + /// **'Download tracks in lossless FLAC quality'** + String get setupModeDownloaderFeature1; + + /// Downloader mode feature 2 + /// + /// In en, this message translates to: + /// **'Save music to your device for offline listening'** + String get setupModeDownloaderFeature2; + + /// Downloader mode feature 3 + /// + /// In en, this message translates to: + /// **'Manage your local music library'** + String get setupModeDownloaderFeature3; + + /// Title for streaming mode option + /// + /// In en, this message translates to: + /// **'Streaming'** + String get setupModeStreamingTitle; + + /// Streaming mode feature 1 + /// + /// In en, this message translates to: + /// **'Stream tracks instantly without downloading'** + String get setupModeStreamingFeature1; + + /// Streaming mode feature 2 + /// + /// In en, this message translates to: + /// **'Smart Queue auto-discovers new music for you'** + String get setupModeStreamingFeature2; + + /// Streaming mode feature 3 + /// + /// In en, this message translates to: + /// **'Play any track on demand with playback controls'** + String get setupModeStreamingFeature3; + + /// Hint that mode can be changed later + /// + /// In en, this message translates to: + /// **'You can switch between modes anytime in Settings.'** + String get setupModeChangeableLater; + + /// Title for Smart Queue toggle in settings + /// + /// In en, this message translates to: + /// **'Smart Queue'** + String get settingsSmartQueueTitle; + + /// Subtitle for Smart Queue toggle in settings + /// + /// In en, this message translates to: + /// **'Automatically discover and add similar tracks to your queue'** + String get settingsSmartQueueSubtitle; + + /// Title for the What's New screen + /// + /// In en, this message translates to: + /// **'What\'s New in 4.0'** + String get whatsNewTitle; + + /// Subtitle for the What's New screen + /// + /// In en, this message translates to: + /// **'SpotiFLAC has evolved — here\'s what changed since 3.x'** + String get whatsNewSubtitle; + + /// Welcome page title in What's New screen + /// + /// In en, this message translates to: + /// **'SpotiFLAC Mobile 4.0'** + String get whatsNewWelcomeTitle; + + /// Welcome page description in What's New screen + /// + /// In en, this message translates to: + /// **'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'** + String get whatsNewWelcomeDesc; + + /// Welcome page tip 1 + /// + /// In en, this message translates to: + /// **'New streaming mode with instant playback'** + String get whatsNewWelcomeTip1; + + /// Welcome page tip 2 + /// + /// In en, this message translates to: + /// **'Redesigned library and full-screen player'** + String get whatsNewWelcomeTip2; + + /// Welcome page tip 3 + /// + /// In en, this message translates to: + /// **'Batch tools, performance boosts, and more'** + String get whatsNewWelcomeTip3; + + /// What's New feature: Streaming Mode title + /// + /// In en, this message translates to: + /// **'Streaming Mode'** + String get whatsNewStreamingTitle; + + /// What's New feature: Streaming Mode description + /// + /// In en, this message translates to: + /// **'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'** + String get whatsNewStreamingDesc; + + /// What's New feature: Smart Queue title + /// + /// In en, this message translates to: + /// **'Smart Queue'** + String get whatsNewSmartQueueTitle; + + /// What's New feature: Smart Queue description + /// + /// In en, this message translates to: + /// **'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'** + String get whatsNewSmartQueueDesc; + + /// What's New feature: Dual Mode title + /// + /// In en, this message translates to: + /// **'Dual Mode'** + String get whatsNewDualModeTitle; + + /// What's New feature: Dual Mode description + /// + /// In en, this message translates to: + /// **'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'** + String get whatsNewDualModeDesc; + + /// What's New feature: Library redesign title + /// + /// In en, this message translates to: + /// **'Redesigned Library'** + String get whatsNewLibraryTitle; + + /// What's New feature: Library redesign description + /// + /// In en, this message translates to: + /// **'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'** + String get whatsNewLibraryDesc; + + /// What's New feature: Full-Screen Player title + /// + /// In en, this message translates to: + /// **'Full-Screen Player'** + String get whatsNewPlayerTitle; + + /// What's New feature: Full-Screen Player description + /// + /// In en, this message translates to: + /// **'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'** + String get whatsNewPlayerDesc; + + /// What's New feature: Context Menus title + /// + /// In en, this message translates to: + /// **'Long-Press Menus'** + String get whatsNewContextMenuTitle; + + /// What's New feature: Context Menus description + /// + /// In en, this message translates to: + /// **'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'** + String get whatsNewContextMenuDesc; + + /// What's New feature: Performance title + /// + /// In en, this message translates to: + /// **'Performance'** + String get whatsNewPerformanceTitle; + + /// What's New feature: Performance description + /// + /// In en, this message translates to: + /// **'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'** + String get whatsNewPerformanceDesc; + + /// What's New feature: Batch Tools title + /// + /// In en, this message translates to: + /// **'Batch Tools'** + String get whatsNewBatchToolsTitle; + + /// What's New feature: Batch Tools description + /// + /// In en, this message translates to: + /// **'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'** + String get whatsNewBatchToolsDesc; + + /// What's New tip: streaming instant play + /// + /// In en, this message translates to: + /// **'Tap any track to start playing instantly'** + String get whatsNewStreamingTip1; + + /// What's New tip: streaming synced lyrics + /// + /// In en, this message translates to: + /// **'Synced lyrics in the full-screen player'** + String get whatsNewStreamingTip2; + + /// What's New tip: streaming download from player + /// + /// In en, this message translates to: + /// **'Download tracks directly from the player'** + String get whatsNewStreamingTip3; + + /// What's New tip: smart queue auto-fill + /// + /// In en, this message translates to: + /// **'Queue auto-fills with related tracks'** + String get whatsNewSmartQueueTip1; + + /// What's New tip: smart queue artist discovery + /// + /// In en, this message translates to: + /// **'Discover new artists as you listen'** + String get whatsNewSmartQueueTip2; + + /// What's New tip: smart queue endless + /// + /// In en, this message translates to: + /// **'Never run out of music to play'** + String get whatsNewSmartQueueTip3; + + /// What's New tip: dual mode switch + /// + /// In en, this message translates to: + /// **'Switch modes anytime in Settings'** + String get whatsNewDualModeTip1; + + /// What's New tip: dual mode adaptive UI + /// + /// In en, this message translates to: + /// **'UI buttons adapt to your current mode'** + String get whatsNewDualModeTip2; + + /// What's New tip: dual mode use cases + /// + /// In en, this message translates to: + /// **'Download for offline, stream for instant play'** + String get whatsNewDualModeTip3; + + /// What's New tip: library drag and drop + /// + /// In en, this message translates to: + /// **'Drag and drop to organize playlists'** + String get whatsNewLibraryTip1; + + /// What's New tip: library custom covers + /// + /// In en, this message translates to: + /// **'Set custom cover images for playlists'** + String get whatsNewLibraryTip2; + + /// What's New tip: library multi-select + /// + /// In en, this message translates to: + /// **'Multi-select tracks for batch actions'** + String get whatsNewLibraryTip3; + + /// What's New tip: player parallax + /// + /// In en, this message translates to: + /// **'Cover art with parallax scrolling effect'** + String get whatsNewPlayerTip1; + + /// What's New tip: player persistence + /// + /// In en, this message translates to: + /// **'Playback persists across app restarts'** + String get whatsNewPlayerTip2; + + /// What's New tip: player lyrics + /// + /// In en, this message translates to: + /// **'Synced lyrics while you listen'** + String get whatsNewPlayerTip3; + + /// What's New tip: context menu add to playlist + /// + /// In en, this message translates to: + /// **'Add tracks to any playlist instantly'** + String get whatsNewContextMenuTip1; + + /// What's New tip: context menu share/convert + /// + /// In en, this message translates to: + /// **'Share or convert with one tap'** + String get whatsNewContextMenuTip2; + + /// What's New tip: context menu re-enrich + /// + /// In en, this message translates to: + /// **'Re-enrich metadata when needed'** + String get whatsNewContextMenuTip3; + + /// What's New tip: batch share + /// + /// In en, this message translates to: + /// **'Share multiple tracks at once'** + String get whatsNewBatchToolsTip1; + + /// What's New tip: batch convert + /// + /// In en, this message translates to: + /// **'Batch convert to MP3 or Opus format'** + String get whatsNewBatchToolsTip2; + + /// What's New tip: batch re-enrich + /// + /// In en, this message translates to: + /// **'Re-enrich metadata across your library'** + String get whatsNewBatchToolsTip3; + + /// What's New tip: performance startup + /// + /// In en, this message translates to: + /// **'Faster app startup time'** + String get whatsNewPerformanceTip1; + + /// What's New tip: performance memory + /// + /// In en, this message translates to: + /// **'Reduced memory usage during playback'** + String get whatsNewPerformanceTip2; + + /// What's New tip: performance SQLite + /// + /// In en, this message translates to: + /// **'SQLite-backed storage for reliability'** + String get whatsNewPerformanceTip3; + + /// Ready card message on last What's New page + /// + /// In en, this message translates to: + /// **'You\'re all set — enjoy the new SpotiFLAC!'** + String get whatsNewReadyMessage; + + /// Button text to dismiss What's New screen + /// + /// In en, this message translates to: + /// **'Let\'s Go'** + String get whatsNewGetStarted; + + /// Page indicator text in What's New screen + /// + /// In en, this message translates to: + /// **'{current} of {total}'** + String whatsNewPageIndicator(int current, int total); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 3663483d..30bea9cf 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -251,6 +251,33 @@ class AppLocalizationsDe extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Andere Dienste versuchen, wenn Download fehlschlägt'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden'; @@ -1057,6 +1084,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get errorNoTracksFound => 'Keine Titel gefunden'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle'; @@ -1578,6 +1609,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2256,9 +2292,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2303,6 +2345,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3202,4 +3247,218 @@ class AppLocalizationsDe extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Wähle deinen Modus'; + + @override + String get setupModeSelectionDescription => + 'Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Lade Titel in verlustfreier FLAC-Qualität herunter'; + + @override + String get setupModeDownloaderFeature2 => + 'Speichere Musik auf deinem Gerät zum Offline-Hören'; + + @override + String get setupModeDownloaderFeature3 => + 'Verwalte deine lokale Musikbibliothek'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Streame Titel sofort ohne Herunterladen'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue entdeckt automatisch neue Musik für dich'; + + @override + String get setupModeStreamingFeature3 => + 'Spiele jeden Titel auf Abruf mit Wiedergabesteuerung'; + + @override + String get setupModeChangeableLater => + 'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3864a2a6..e9544e94 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -248,6 +248,33 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,4 +3226,217 @@ class AppLocalizationsEn extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Choose Your Mode'; + + @override + String get setupModeSelectionDescription => + 'How would you like to use SpotiFLAC? You can always change this later in Settings.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Download tracks in lossless FLAC quality'; + + @override + String get setupModeDownloaderFeature2 => + 'Save music to your device for offline listening'; + + @override + String get setupModeDownloaderFeature3 => 'Manage your local music library'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Stream tracks instantly without downloading'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue auto-discovers new music for you'; + + @override + String get setupModeStreamingFeature3 => + 'Play any track on demand with playback controls'; + + @override + String get setupModeChangeableLater => + 'You can switch between modes anytime in Settings.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Automatically discover and add similar tracks to your queue'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4184f353..5f805c85 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -248,6 +248,33 @@ class AppLocalizationsEs extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,6 +3226,220 @@ class AppLocalizationsEs extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Elige tu modo'; + + @override + String get setupModeSelectionDescription => + '¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.'; + + @override + String get setupModeDownloaderTitle => 'Descargador'; + + @override + String get setupModeDownloaderFeature1 => + 'Descarga pistas en calidad FLAC sin pérdida'; + + @override + String get setupModeDownloaderFeature2 => + 'Guarda música en tu dispositivo para escuchar sin conexión'; + + @override + String get setupModeDownloaderFeature3 => + 'Gestiona tu biblioteca de música local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmite pistas al instante sin descargar'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descubre automáticamente nueva música para ti'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduce cualquier pista bajo demanda con controles de reproducción'; + + @override + String get setupModeChangeableLater => + 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Descubre y añade automáticamente pistas similares a tu cola de reproducción'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). @@ -6147,4 +6406,52 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => 'Elige tu modo'; + + @override + String get setupModeSelectionDescription => + '¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.'; + + @override + String get setupModeDownloaderTitle => 'Descargador'; + + @override + String get setupModeDownloaderFeature1 => + 'Descarga pistas en calidad FLAC sin pérdida'; + + @override + String get setupModeDownloaderFeature2 => + 'Guarda música en tu dispositivo para escuchar sin conexión'; + + @override + String get setupModeDownloaderFeature3 => + 'Gestiona tu biblioteca de música local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmite pistas al instante sin descargar'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descubre automáticamente nueva música para ti'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduce cualquier pista bajo demanda con controles de reproducción'; + + @override + String get setupModeChangeableLater => + 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Descubre y añade automáticamente pistas similares a tu cola de reproducción'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 76b5227c..2909fc46 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -253,6 +253,33 @@ class AppLocalizationsFr extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Essayez d\'autres services si le téléchargement échoue'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Utiliser des fournisseurs d\'extension'; @@ -1047,6 +1074,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1563,6 +1594,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2241,9 +2277,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2288,6 +2330,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3187,4 +3232,218 @@ class AppLocalizationsFr extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Choisissez votre mode'; + + @override + String get setupModeSelectionDescription => + 'Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.'; + + @override + String get setupModeDownloaderTitle => 'Téléchargeur'; + + @override + String get setupModeDownloaderFeature1 => + 'Téléchargez des pistes en qualité FLAC sans perte'; + + @override + String get setupModeDownloaderFeature2 => + 'Enregistrez de la musique sur votre appareil pour une écoute hors ligne'; + + @override + String get setupModeDownloaderFeature3 => + 'Gérez votre bibliothèque musicale locale'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Diffusez des pistes instantanément sans télécharger'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue découvre automatiquement de nouvelle musique pour vous'; + + @override + String get setupModeStreamingFeature3 => + 'Écoutez n\'importe quelle piste à la demande avec les contrôles de lecture'; + + @override + String get setupModeChangeableLater => + 'Vous pouvez changer de mode à tout moment dans les Paramètres.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Découvrir et ajouter automatiquement des pistes similaires à votre file d\'attente'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 36ae71f0..8b90147c 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -248,6 +248,33 @@ class AppLocalizationsHi extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsHi extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsHi extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,4 +3226,218 @@ class AppLocalizationsHi extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'अपना मोड चुनें'; + + @override + String get setupModeSelectionDescription => + 'आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।'; + + @override + String get setupModeDownloaderTitle => 'डाउनलोडर'; + + @override + String get setupModeDownloaderFeature1 => + 'लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें'; + + @override + String get setupModeDownloaderFeature2 => + 'ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें'; + + @override + String get setupModeDownloaderFeature3 => + 'अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें'; + + @override + String get setupModeStreamingTitle => 'स्ट्रीमिंग'; + + @override + String get setupModeStreamingFeature1 => + 'बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है'; + + @override + String get setupModeStreamingFeature3 => + 'प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं'; + + @override + String get setupModeChangeableLater => + 'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a6820bb7..7a9fad81 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -251,6 +251,34 @@ class AppLocalizationsId extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Coba layanan lain jika unduhan gagal'; + @override + String get optionsAutoSkipUnavailableTracks => + 'Lewati Otomatis Lagu yang Tidak Tersedia'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Berhenti di lagu yang gagal dan tampilkan pesan error.'; + + @override + String get optionsInteractionMode => 'Mode Interaksi'; + + @override + String get modeDownloader => 'Mode Downloader'; + + @override + String get modeDownloaderSubtitle => + 'Ketuk lagu untuk menambah ke antrean unduhan'; + + @override + String get modeStreaming => 'Mode Streaming'; + + @override + String get modeStreamingSubtitle => 'Ketuk lagu untuk langsung memutar'; + @override String get optionsUseExtensionProviders => 'Gunakan Provider Ekstensi'; @@ -1047,6 +1075,10 @@ class AppLocalizationsId extends AppLocalizations { @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; + @override + String get errorSeekNotSupported => + 'Menggeser posisi lagu tidak didukung untuk live stream ini'; + @override String errorMissingExtensionSource(String item) { return 'Tidak dapat memuat $item: sumber ekstensi tidak ada'; @@ -1567,6 +1599,11 @@ class AppLocalizationsId extends AppLocalizations { return 'Unduh Semua ($count)'; } + @override + String playAllCount(int count) { + return 'Putar Semua ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2248,9 +2285,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Putar Diskografi'; + @override String get discographyDownloadAll => 'Unduh Semua'; + @override + String get discographyPlayAll => 'Putar Semua'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2295,6 +2338,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Putar Terpilih'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3144,31 +3190,32 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'trek', + one: 'trek', ); - return 'Share $count $_temp0'; + return 'Bagikan $count $_temp0'; } @override - String get selectionShareNoFiles => 'No shareable files found'; + String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan'; @override String selectionConvertCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'trek', + one: 'trek', ); - return 'Convert $count $_temp0'; + return 'Konversi $count $_temp0'; } @override - String get selectionConvertNoConvertible => 'No convertible tracks selected'; + String get selectionConvertNoConvertible => + 'Tidak ada trek yang dapat dikonversi dipilih'; @override - String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + String get selectionBatchConvertConfirmTitle => 'Konversi Massal'; @override String selectionBatchConvertConfirmMessage( @@ -3179,19 +3226,242 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'trek', + one: 'trek', ); - return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.'; } @override String selectionBatchConvertProgress(int current, int total) { - return 'Converting $current of $total...'; + return 'Mengonversi $current dari $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Converted $success of $total tracks to $format'; + return 'Berhasil mengonversi $success dari $total trek ke $format'; + } + + @override + String get setupModeSelectionTitle => 'Pilih Mode Anda'; + + @override + String get setupModeSelectionDescription => + 'Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.'; + + @override + String get setupModeDownloaderTitle => 'Pengunduh'; + + @override + String get setupModeDownloaderFeature1 => + 'Unduh trek dalam kualitas FLAC lossless'; + + @override + String get setupModeDownloaderFeature2 => + 'Simpan musik ke perangkat Anda untuk mendengarkan offline'; + + @override + String get setupModeDownloaderFeature3 => + 'Kelola perpustakaan musik lokal Anda'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Streaming trek secara instan tanpa mengunduh'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue secara otomatis menemukan musik baru untuk Anda'; + + @override + String get setupModeStreamingFeature3 => + 'Putar trek apa pun sesuai permintaan dengan kontrol pemutaran'; + + @override + String get setupModeChangeableLater => + 'Anda dapat beralih antar mode kapan saja di Pengaturan.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda'; + + @override + String get whatsNewTitle => 'Yang Baru di 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.'; + + @override + String get whatsNewWelcomeTip1 => + 'Mode streaming baru dengan pemutaran instan'; + + @override + String get whatsNewWelcomeTip2 => + 'Perpustakaan dan pemutar layar penuh yang didesain ulang'; + + @override + String get whatsNewWelcomeTip3 => + 'Alat massal, peningkatan performa, dan lainnya'; + + @override + String get whatsNewStreamingTitle => 'Mode Streaming'; + + @override + String get whatsNewStreamingDesc => + 'Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.'; + + @override + String get whatsNewDualModeTitle => 'Mode Ganda'; + + @override + String get whatsNewDualModeDesc => + 'Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.'; + + @override + String get whatsNewLibraryTitle => 'Perpustakaan Baru'; + + @override + String get whatsNewLibraryDesc => + 'Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.'; + + @override + String get whatsNewPlayerTitle => 'Pemutar Layar Penuh'; + + @override + String get whatsNewPlayerDesc => + 'Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.'; + + @override + String get whatsNewContextMenuTitle => 'Menu Tekan Lama'; + + @override + String get whatsNewContextMenuDesc => + 'Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performa'; + + @override + String get whatsNewPerformanceDesc => + 'Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.'; + + @override + String get whatsNewBatchToolsTitle => 'Alat Massal'; + + @override + String get whatsNewBatchToolsDesc => + 'Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.'; + + @override + String get whatsNewStreamingTip1 => + 'Ketuk trek apa pun untuk langsung memutar'; + + @override + String get whatsNewStreamingTip2 => 'Lirik tersinkron di pemutar layar penuh'; + + @override + String get whatsNewStreamingTip3 => 'Unduh trek langsung dari pemutar'; + + @override + String get whatsNewSmartQueueTip1 => + 'Antrean terisi otomatis dengan trek terkait'; + + @override + String get whatsNewSmartQueueTip2 => 'Temukan artis baru saat mendengarkan'; + + @override + String get whatsNewSmartQueueTip3 => + 'Tak pernah kehabisan musik untuk diputar'; + + @override + String get whatsNewDualModeTip1 => 'Beralih mode kapan saja di Pengaturan'; + + @override + String get whatsNewDualModeTip2 => 'Tombol UI menyesuaikan dengan mode Anda'; + + @override + String get whatsNewDualModeTip3 => + 'Unduh untuk offline, streaming untuk putar langsung'; + + @override + String get whatsNewLibraryTip1 => 'Seret dan lepas untuk mengatur playlist'; + + @override + String get whatsNewLibraryTip2 => 'Atur gambar sampul kustom untuk playlist'; + + @override + String get whatsNewLibraryTip3 => 'Pilih banyak trek untuk aksi massal'; + + @override + String get whatsNewPlayerTip1 => 'Seni sampul dengan efek paralaks'; + + @override + String get whatsNewPlayerTip2 => 'Pemutaran tetap tersimpan saat restart'; + + @override + String get whatsNewPlayerTip3 => 'Lirik tersinkron saat mendengarkan'; + + @override + String get whatsNewContextMenuTip1 => + 'Tambahkan trek ke playlist mana pun langsung'; + + @override + String get whatsNewContextMenuTip2 => + 'Bagikan atau konversi dengan satu ketukan'; + + @override + String get whatsNewContextMenuTip3 => 'Perbarui metadata saat diperlukan'; + + @override + String get whatsNewBatchToolsTip1 => 'Bagikan banyak trek sekaligus'; + + @override + String get whatsNewBatchToolsTip2 => + 'Konversi massal ke format MP3 atau Opus'; + + @override + String get whatsNewBatchToolsTip3 => + 'Perbarui metadata di seluruh perpustakaan'; + + @override + String get whatsNewPerformanceTip1 => 'Waktu startup aplikasi lebih cepat'; + + @override + String get whatsNewPerformanceTip2 => + 'Penggunaan memori berkurang saat pemutaran'; + + @override + String get whatsNewPerformanceTip3 => + 'Penyimpanan berbasis SQLite untuk keandalan'; + + @override + String get whatsNewReadyMessage => 'Siap — nikmati SpotiFLAC yang baru!'; + + @override + String get whatsNewGetStarted => 'Ayo Mulai'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current dari $total'; } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 2e64efd3..7628d16b 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -248,6 +248,33 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する'; @@ -1035,6 +1062,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get errorNoTracksFound => 'トラックがありません'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return '$item を読み込めません: 拡張ソースがありません'; @@ -1550,6 +1581,11 @@ class AppLocalizationsJa extends AppLocalizations { return 'すべてダウンロード ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2221,9 +2257,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discographyDownload => 'ディスコグラフィをダウンロード'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'すべてダウンロード'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$albumCount 個のリリースから $count 個のトラック'; @@ -2268,6 +2310,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discographyDownloadSelected => '選択済みをダウンロード'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3167,4 +3212,210 @@ class AppLocalizationsJa extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'モードを選択'; + + @override + String get setupModeSelectionDescription => + 'SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。'; + + @override + String get setupModeDownloaderTitle => 'ダウンローダー'; + + @override + String get setupModeDownloaderFeature1 => 'ロスレスFLAC品質でトラックをダウンロード'; + + @override + String get setupModeDownloaderFeature2 => 'オフライン再生用に音楽をデバイスに保存'; + + @override + String get setupModeDownloaderFeature3 => 'ローカル音楽ライブラリを管理'; + + @override + String get setupModeStreamingTitle => 'ストリーミング'; + + @override + String get setupModeStreamingFeature1 => 'ダウンロードせずにトラックを即座にストリーミング'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queueが自動的に新しい音楽を見つけます'; + + @override + String get setupModeStreamingFeature3 => '再生コントロールで任意のトラックをオンデマンド再生'; + + @override + String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => '類似トラックを自動的に検出してキューに追加'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 32d7f843..c9aa8d92 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -247,6 +247,33 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1040,6 +1067,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1556,6 +1587,11 @@ class AppLocalizationsKo extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2234,9 +2270,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2281,6 +2323,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3180,4 +3225,210 @@ class AppLocalizationsKo extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => '모드 선택'; + + @override + String get setupModeSelectionDescription => + 'SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.'; + + @override + String get setupModeDownloaderTitle => '다운로더'; + + @override + String get setupModeDownloaderFeature1 => '무손실 FLAC 품질로 트랙 다운로드'; + + @override + String get setupModeDownloaderFeature2 => '오프라인 감상을 위해 기기에 음악 저장'; + + @override + String get setupModeDownloaderFeature3 => '로컬 음악 라이브러리 관리'; + + @override + String get setupModeStreamingTitle => '스트리밍'; + + @override + String get setupModeStreamingFeature1 => '다운로드 없이 트랙을 즉시 스트리밍'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue가 자동으로 새로운 음악을 발견합니다'; + + @override + String get setupModeStreamingFeature3 => '재생 컨트롤로 원하는 트랙을 온디맨드 재생'; + + @override + String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => '유사한 트랙을 자동으로 검색하여 대기열에 추가'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 4f263f2d..ed4810d0 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -248,6 +248,33 @@ class AppLocalizationsNl extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,4 +3226,218 @@ class AppLocalizationsNl extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Kies je modus'; + + @override + String get setupModeSelectionDescription => + 'Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Download nummers in lossless FLAC-kwaliteit'; + + @override + String get setupModeDownloaderFeature2 => + 'Sla muziek op je apparaat op om offline te luisteren'; + + @override + String get setupModeDownloaderFeature3 => + 'Beheer je lokale muziekbibliotheek'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Stream nummers direct zonder te downloaden'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue ontdekt automatisch nieuwe muziek voor je'; + + @override + String get setupModeStreamingFeature3 => + 'Speel elk nummer op aanvraag af met afspeelbediening'; + + @override + String get setupModeChangeableLater => + 'Je kunt op elk moment wisselen tussen modi in Instellingen.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 2ab77278..9a341fad 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -248,6 +248,33 @@ class AppLocalizationsPt extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,6 +3226,220 @@ class AppLocalizationsPt extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Escolha seu modo'; + + @override + String get setupModeSelectionDescription => + 'Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Baixe faixas em qualidade FLAC lossless'; + + @override + String get setupModeDownloaderFeature2 => + 'Salve músicas no seu dispositivo para ouvir offline'; + + @override + String get setupModeDownloaderFeature3 => + 'Gerencie sua biblioteca de músicas local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmita faixas instantaneamente sem baixar'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descobre automaticamente novas músicas para você'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduza qualquer faixa sob demanda com controles de reprodução'; + + @override + String get setupModeChangeableLater => + 'Você pode alternar entre os modos a qualquer momento nas Configurações.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Descubra e adicione automaticamente faixas semelhantes à sua fila'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -6141,4 +6400,52 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => 'Escolha o seu modo'; + + @override + String get setupModeSelectionDescription => + 'Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.'; + + @override + String get setupModeDownloaderTitle => 'Transferência'; + + @override + String get setupModeDownloaderFeature1 => + 'Transfira faixas em qualidade FLAC sem perdas'; + + @override + String get setupModeDownloaderFeature2 => + 'Guarde música no seu dispositivo para ouvir offline'; + + @override + String get setupModeDownloaderFeature3 => + 'Faça a gestão da sua biblioteca de música local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmita faixas instantaneamente sem transferir'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descobre automaticamente novas músicas para si'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduza qualquer faixa a pedido com controlos de reprodução'; + + @override + String get setupModeChangeableLater => + 'Pode alternar entre modos a qualquer momento nas Definições.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Descubra e adicione automaticamente faixas semelhantes à sua fila'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index dbd95cc2..3df13ff8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -255,6 +255,33 @@ class AppLocalizationsRu extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Попробовать другие сервисы при сбое загрузки'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Использовать провайдера расширений'; @@ -1066,6 +1093,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get errorNoTracksFound => 'Треки не найдены'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Невозможно загрузить $item: отсутствует источник расширения'; @@ -1587,6 +1618,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Скачать все ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2283,9 +2319,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discographyDownload => 'Скачать дискографию'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Скачать всё'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count треков из $albumCount релизов'; @@ -2330,6 +2372,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discographyDownloadSelected => 'Скачать выбранное'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Добавлено $count треков в очередь'; @@ -3279,4 +3324,218 @@ class AppLocalizationsRu extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Выберите режим'; + + @override + String get setupModeSelectionDescription => + 'Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.'; + + @override + String get setupModeDownloaderTitle => 'Загрузчик'; + + @override + String get setupModeDownloaderFeature1 => + 'Скачивайте треки в качестве FLAC без потерь'; + + @override + String get setupModeDownloaderFeature2 => + 'Сохраняйте музыку на устройство для прослушивания офлайн'; + + @override + String get setupModeDownloaderFeature3 => + 'Управляйте своей локальной музыкальной библиотекой'; + + @override + String get setupModeStreamingTitle => 'Стриминг'; + + @override + String get setupModeStreamingFeature1 => + 'Слушайте треки мгновенно без скачивания'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue автоматически подбирает новую музыку для вас'; + + @override + String get setupModeStreamingFeature3 => + 'Воспроизводите любой трек по запросу с элементами управления'; + + @override + String get setupModeChangeableLater => + 'Вы можете переключаться между режимами в любое время в Настройках.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Автоматически находите и добавляйте похожие треки в очередь воспроизведения'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index dda62af2..935c31d4 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -252,6 +252,33 @@ class AppLocalizationsTr extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'İndirme başarısız olursa diğer hizmetleri dene'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Eklenti sağlayıcılarını kullan'; @@ -1048,6 +1075,10 @@ class AppLocalizationsTr extends AppLocalizations { @override String get errorNoTracksFound => 'Parça bulunamadı'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return '$item yüklenemedi: Eksik eklenti kaynağı'; @@ -1570,6 +1601,11 @@ class AppLocalizationsTr extends AppLocalizations { return 'Tümünü İndir ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2250,9 +2286,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2297,6 +2339,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return '$count şarkı kuyruğa eklendi'; @@ -3196,4 +3241,217 @@ class AppLocalizationsTr extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Modunuzu Seçin'; + + @override + String get setupModeSelectionDescription => + 'SpotiFLAC\'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar\'dan değiştirebilirsiniz.'; + + @override + String get setupModeDownloaderTitle => 'İndirici'; + + @override + String get setupModeDownloaderFeature1 => + 'Kayıpsız FLAC kalitesinde parça indirin'; + + @override + String get setupModeDownloaderFeature2 => + 'Çevrimdışı dinlemek için müziği cihazınıza kaydedin'; + + @override + String get setupModeDownloaderFeature3 => 'Yerel müzik kütüphanenizi yönetin'; + + @override + String get setupModeStreamingTitle => 'Yayın Akışı'; + + @override + String get setupModeStreamingFeature1 => + 'İndirmeden parçaları anında yayınlayın'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue sizin için otomatik olarak yeni müzik keşfeder'; + + @override + String get setupModeStreamingFeature3 => + 'İstediğiniz parçayı oynatma kontrolleriyle çalın'; + + @override + String get setupModeChangeableLater => + 'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => + 'Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9021f5dc..c4a6f4aa 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -248,6 +248,33 @@ class AppLocalizationsZh extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsZh extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,6 +3226,211 @@ class AppLocalizationsZh extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => '选择您的模式'; + + @override + String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。'; + + @override + String get setupModeDownloaderTitle => '下载器'; + + @override + String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目'; + + @override + String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听'; + + @override + String get setupModeDownloaderFeature3 => '管理您的本地音乐库'; + + @override + String get setupModeStreamingTitle => '流媒体'; + + @override + String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐'; + + @override + String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目'; + + @override + String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; + + @override + String get whatsNewTitle => 'What\'s New in 4.0'; + + @override + String get whatsNewSubtitle => + 'SpotiFLAC has evolved — here\'s what changed since 3.x'; + + @override + String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; + + @override + String get whatsNewWelcomeDesc => + 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; + + @override + String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; + + @override + String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; + + @override + String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; + + @override + String get whatsNewStreamingTitle => 'Streaming Mode'; + + @override + String get whatsNewStreamingDesc => + 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; + + @override + String get whatsNewSmartQueueTitle => 'Smart Queue'; + + @override + String get whatsNewSmartQueueDesc => + 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; + + @override + String get whatsNewDualModeTitle => 'Dual Mode'; + + @override + String get whatsNewDualModeDesc => + 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; + + @override + String get whatsNewLibraryTitle => 'Redesigned Library'; + + @override + String get whatsNewLibraryDesc => + 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; + + @override + String get whatsNewPlayerTitle => 'Full-Screen Player'; + + @override + String get whatsNewPlayerDesc => + 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; + + @override + String get whatsNewContextMenuTitle => 'Long-Press Menus'; + + @override + String get whatsNewContextMenuDesc => + 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; + + @override + String get whatsNewPerformanceTitle => 'Performance'; + + @override + String get whatsNewPerformanceDesc => + 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; + + @override + String get whatsNewBatchToolsTitle => 'Batch Tools'; + + @override + String get whatsNewBatchToolsDesc => + 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; + + @override + String get whatsNewStreamingTip1 => + 'Tap any track to start playing instantly'; + + @override + String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; + + @override + String get whatsNewStreamingTip3 => + 'Download tracks directly from the player'; + + @override + String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; + + @override + String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; + + @override + String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; + + @override + String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; + + @override + String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; + + @override + String get whatsNewDualModeTip3 => + 'Download for offline, stream for instant play'; + + @override + String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; + + @override + String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; + + @override + String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; + + @override + String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; + + @override + String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; + + @override + String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; + + @override + String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; + + @override + String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; + + @override + String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; + + @override + String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; + + @override + String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; + + @override + String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; + + @override + String get whatsNewPerformanceTip1 => 'Faster app startup time'; + + @override + String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; + + @override + String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; + + @override + String get whatsNewReadyMessage => + 'You\'re all set — enjoy the new SpotiFLAC!'; + + @override + String get whatsNewGetStarted => 'Let\'s Go'; + + @override + String whatsNewPageIndicator(int current, int total) { + return '$current of $total'; + } } /// The translations for Chinese, as used in China (`zh_CN`). @@ -6114,6 +6364,45 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => '选择您的模式'; + + @override + String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。'; + + @override + String get setupModeDownloaderTitle => '下载器'; + + @override + String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目'; + + @override + String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听'; + + @override + String get setupModeDownloaderFeature3 => '管理您的本地音乐库'; + + @override + String get setupModeStreamingTitle => '流媒体'; + + @override + String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐'; + + @override + String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目'; + + @override + String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -9047,4 +9336,43 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => '選擇您的模式'; + + @override + String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。'; + + @override + String get setupModeDownloaderTitle => '下載器'; + + @override + String get setupModeDownloaderFeature1 => '以無損 FLAC 品質下載曲目'; + + @override + String get setupModeDownloaderFeature2 => '將音樂儲存到裝置以供離線收聽'; + + @override + String get setupModeDownloaderFeature3 => '管理您的本機音樂庫'; + + @override + String get setupModeStreamingTitle => '串流'; + + @override + String get setupModeStreamingFeature1 => '無需下載即可即時串流曲目'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue 自動為您探索新音樂'; + + @override + String get setupModeStreamingFeature3 => '透過播放控制項隨時點播任意曲目'; + + @override + String get setupModeChangeableLater => '您可以隨時在設定中切換模式。'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => '自動探索並將相似曲目新增到您的佇列中'; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index e8dba80d..cf06c2ed 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Wähle deinen Modus", + "setupModeSelectionDescription": "Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.", + "setupModeDownloaderTitle": "Downloader", + "setupModeDownloaderFeature1": "Lade Titel in verlustfreier FLAC-Qualität herunter", + "setupModeDownloaderFeature2": "Speichere Musik auf deinem Gerät zum Offline-Hören", + "setupModeDownloaderFeature3": "Verwalte deine lokale Musikbibliothek", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen", + "setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich", + "setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung", + "setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen" } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1e0f3572..562073ff 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -175,6 +175,22 @@ "@optionsAutoFallback": {"description": "Auto-retry with other services"}, "optionsAutoFallbackSubtitle": "Try other services if download fails", "@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"}, + "optionsAutoSkipUnavailableTracks": "Auto Skip Unavailable Tracks", + "@optionsAutoSkipUnavailableTracks": {"description": "Toggle to skip to the next queue track when current track stream resolution fails"}, + "optionsAutoSkipUnavailableTracksSubtitleOn": "Automatically skip to the next queue track when a stream cannot be resolved.", + "@optionsAutoSkipUnavailableTracksSubtitleOn": {"description": "Subtitle when auto skip on resolve failure is enabled"}, + "optionsAutoSkipUnavailableTracksSubtitleOff": "Stop on failed track resolution and show an error.", + "@optionsAutoSkipUnavailableTracksSubtitleOff": {"description": "Subtitle when auto skip on resolve failure is disabled"}, + "optionsInteractionMode": "Interaction Mode", + "@optionsInteractionMode": {"description": "Tap behavior mode for track lists"}, + "modeDownloader": "Downloader Mode", + "@modeDownloader": {"description": "Interaction mode where taps queue downloads"}, + "modeDownloaderSubtitle": "Tap tracks to add them to download queue", + "@modeDownloaderSubtitle": {"description": "Subtitle for downloader interaction mode"}, + "modeStreaming": "Streaming Mode", + "@modeStreaming": {"description": "Interaction mode where taps start playback"}, + "modeStreamingSubtitle": "Tap tracks to play instantly", + "@modeStreamingSubtitle": {"description": "Subtitle for streaming interaction mode"}, "optionsUseExtensionProviders": "Use Extension Providers", "@optionsUseExtensionProviders": {"description": "Enable extension download providers"}, "optionsUseExtensionProvidersOn": "Extensions will be tried first", @@ -759,6 +775,8 @@ }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": {"description": "Error - search returned no results"}, + "errorSeekNotSupported": "Seeking is not supported for this live stream", + "@errorSeekNotSupported": {"description": "Error - seek disabled for live decrypted stream"}, "errorMissingExtensionSource": "Cannot load {item}: missing extension source", "@errorMissingExtensionSource": { "description": "Error - extension source not available", @@ -1151,6 +1169,13 @@ "count": {"type": "int"} } }, + "playAllCount": "Play All ({count})", + "@playAllCount": { + "description": "Play all button with count", + "placeholders": { + "count": {"type": "int"} + } + }, "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", "@tracksCount": { "description": "Track count display", @@ -1669,8 +1694,12 @@ "discographyDownload": "Download Discography", "@discographyDownload": {"description": "Button - download artist discography"}, + "discographyPlay": "Play Discography", + "@discographyPlay": {"description": "Button - play artist discography"}, "discographyDownloadAll": "Download All", "@discographyDownloadAll": {"description": "Option - download entire discography"}, + "discographyPlayAll": "Play All", + "@discographyPlayAll": {"description": "Option - play entire discography"}, "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", @@ -1722,6 +1751,8 @@ }, "discographyDownloadSelected": "Download Selected", "@discographyDownloadSelected": {"description": "Button - download selected albums"}, + "discographyPlaySelected": "Play Selected", + "@discographyPlaySelected": {"description": "Button - play selected albums"}, "discographyAddedToQueue": "Added {count} tracks to queue", "@discographyAddedToQueue": { "description": "Snackbar - tracks added from discography", @@ -2441,5 +2472,140 @@ "total": {"type": "int"}, "format": {"type": "String"} } + }, + + "setupModeSelectionTitle": "Choose Your Mode", + "@setupModeSelectionTitle": {"description": "Title for mode selection step in setup wizard"}, + "setupModeSelectionDescription": "How would you like to use SpotiFLAC? You can always change this later in Settings.", + "@setupModeSelectionDescription": {"description": "Description for mode selection step"}, + "setupModeDownloaderTitle": "Downloader", + "@setupModeDownloaderTitle": {"description": "Title for downloader mode option"}, + "setupModeDownloaderFeature1": "Download tracks in lossless FLAC quality", + "@setupModeDownloaderFeature1": {"description": "Downloader mode feature 1"}, + "setupModeDownloaderFeature2": "Save music to your device for offline listening", + "@setupModeDownloaderFeature2": {"description": "Downloader mode feature 2"}, + "setupModeDownloaderFeature3": "Manage your local music library", + "@setupModeDownloaderFeature3": {"description": "Downloader mode feature 3"}, + "setupModeStreamingTitle": "Streaming", + "@setupModeStreamingTitle": {"description": "Title for streaming mode option"}, + "setupModeStreamingFeature1": "Stream tracks instantly without downloading", + "@setupModeStreamingFeature1": {"description": "Streaming mode feature 1"}, + "setupModeStreamingFeature2": "Smart Queue auto-discovers new music for you", + "@setupModeStreamingFeature2": {"description": "Streaming mode feature 2"}, + "setupModeStreamingFeature3": "Play any track on demand with playback controls", + "@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"}, + "setupModeChangeableLater": "You can switch between modes anytime in Settings.", + "@setupModeChangeableLater": {"description": "Hint that mode can be changed later"}, + + "settingsSmartQueueTitle": "Smart Queue", + "@settingsSmartQueueTitle": {"description": "Title for Smart Queue toggle in settings"}, + "settingsSmartQueueSubtitle": "Automatically discover and add similar tracks to your queue", + "@settingsSmartQueueSubtitle": {"description": "Subtitle for Smart Queue toggle in settings"}, + + "whatsNewTitle": "What's New in 4.0", + "@whatsNewTitle": {"description": "Title for the What's New screen"}, + "whatsNewSubtitle": "SpotiFLAC has evolved — here's what changed since 3.x", + "@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"}, + "whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0", + "@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"}, + "whatsNewWelcomeDesc": "Welcome back! This is a major update packed with new features. Swipe through to see what's changed.", + "@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"}, + "whatsNewWelcomeTip1": "New streaming mode with instant playback", + "@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"}, + "whatsNewWelcomeTip2": "Redesigned library and full-screen player", + "@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"}, + "whatsNewWelcomeTip3": "Batch tools, performance boosts, and more", + "@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"}, + "whatsNewStreamingTitle": "Streaming Mode", + "@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"}, + "whatsNewStreamingDesc": "Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.", + "@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"}, + "whatsNewSmartQueueTitle": "Smart Queue", + "@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"}, + "whatsNewSmartQueueDesc": "Your queue auto-curates with related tracks and artist discovery. Never run out of music.", + "@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"}, + "whatsNewDualModeTitle": "Dual Mode", + "@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"}, + "whatsNewDualModeDesc": "Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.", + "@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"}, + "whatsNewLibraryTitle": "Redesigned Library", + "@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"}, + "whatsNewLibraryDesc": "Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.", + "@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"}, + "whatsNewPlayerTitle": "Full-Screen Player", + "@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"}, + "whatsNewPlayerDesc": "Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.", + "@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"}, + "whatsNewContextMenuTitle": "Long-Press Menus", + "@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"}, + "whatsNewContextMenuDesc": "Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.", + "@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"}, + "whatsNewPerformanceTitle": "Performance", + "@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"}, + "whatsNewPerformanceDesc": "Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.", + "@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"}, + "whatsNewBatchToolsTitle": "Batch Tools", + "@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"}, + "whatsNewBatchToolsDesc": "Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.", + "@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"}, + "whatsNewStreamingTip1": "Tap any track to start playing instantly", + "@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"}, + "whatsNewStreamingTip2": "Synced lyrics in the full-screen player", + "@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"}, + "whatsNewStreamingTip3": "Download tracks directly from the player", + "@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"}, + "whatsNewSmartQueueTip1": "Queue auto-fills with related tracks", + "@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"}, + "whatsNewSmartQueueTip2": "Discover new artists as you listen", + "@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"}, + "whatsNewSmartQueueTip3": "Never run out of music to play", + "@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"}, + "whatsNewDualModeTip1": "Switch modes anytime in Settings", + "@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"}, + "whatsNewDualModeTip2": "UI buttons adapt to your current mode", + "@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"}, + "whatsNewDualModeTip3": "Download for offline, stream for instant play", + "@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"}, + "whatsNewLibraryTip1": "Drag and drop to organize playlists", + "@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"}, + "whatsNewLibraryTip2": "Set custom cover images for playlists", + "@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"}, + "whatsNewLibraryTip3": "Multi-select tracks for batch actions", + "@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"}, + "whatsNewPlayerTip1": "Cover art with parallax scrolling effect", + "@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"}, + "whatsNewPlayerTip2": "Playback persists across app restarts", + "@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"}, + "whatsNewPlayerTip3": "Synced lyrics while you listen", + "@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"}, + "whatsNewContextMenuTip1": "Add tracks to any playlist instantly", + "@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"}, + "whatsNewContextMenuTip2": "Share or convert with one tap", + "@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"}, + "whatsNewContextMenuTip3": "Re-enrich metadata when needed", + "@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"}, + "whatsNewBatchToolsTip1": "Share multiple tracks at once", + "@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"}, + "whatsNewBatchToolsTip2": "Batch convert to MP3 or Opus format", + "@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"}, + "whatsNewBatchToolsTip3": "Re-enrich metadata across your library", + "@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"}, + "whatsNewPerformanceTip1": "Faster app startup time", + "@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"}, + "whatsNewPerformanceTip2": "Reduced memory usage during playback", + "@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"}, + "whatsNewPerformanceTip3": "SQLite-backed storage for reliability", + "@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"}, + "whatsNewReadyMessage": "You're all set — enjoy the new SpotiFLAC!", + "@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"}, + "whatsNewGetStarted": "Let's Go", + "@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"}, + "whatsNewPageIndicator": "{current} of {total}", + "@whatsNewPageIndicator": { + "description": "Page indicator text in What's New screen", + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index f499cb14..8d9a8fb9 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2565,5 +2565,18 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" - } + }, + "setupModeSelectionTitle": "Elige tu modo", + "setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.", + "setupModeDownloaderTitle": "Descargador", + "setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida", + "setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión", + "setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", + "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", + "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", + "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción" } \ No newline at end of file diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 5643ebe2..ccb341db 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Elige tu modo", + "setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.", + "setupModeDownloaderTitle": "Descargador", + "setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida", + "setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión", + "setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", + "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", + "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", + "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción" } \ No newline at end of file diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 41034185..351c4531 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Choisissez votre mode", + "setupModeSelectionDescription": "Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.", + "setupModeDownloaderTitle": "Téléchargeur", + "setupModeDownloaderFeature1": "Téléchargez des pistes en qualité FLAC sans perte", + "setupModeDownloaderFeature2": "Enregistrez de la musique sur votre appareil pour une écoute hors ligne", + "setupModeDownloaderFeature3": "Gérez votre bibliothèque musicale locale", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Diffusez des pistes instantanément sans télécharger", + "setupModeStreamingFeature2": "Smart Queue découvre automatiquement de nouvelle musique pour vous", + "setupModeStreamingFeature3": "Écoutez n'importe quelle piste à la demande avec les contrôles de lecture", + "setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Découvrir et ajouter automatiquement des pistes similaires à votre file d'attente" } \ No newline at end of file diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 71d38aab..7517340d 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "अपना मोड चुनें", + "setupModeSelectionDescription": "आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।", + "setupModeDownloaderTitle": "डाउनलोडर", + "setupModeDownloaderFeature1": "लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें", + "setupModeDownloaderFeature2": "ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें", + "setupModeDownloaderFeature3": "अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें", + "setupModeStreamingTitle": "स्ट्रीमिंग", + "setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें", + "setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है", + "setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं", + "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें" } \ No newline at end of file diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b74ffdb1..c2d4b7d8 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -300,15 +300,47 @@ "@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", + "optionsAutoFallback": "Auto Fallback", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, + "optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, + "optionsAutoSkipUnavailableTracks": "Lewati Otomatis Lagu yang Tidak Tersedia", + "@optionsAutoSkipUnavailableTracks": { + "description": "Toggle to skip to the next queue track when current track stream resolution fails" + }, + "optionsAutoSkipUnavailableTracksSubtitleOn": "Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.", + "@optionsAutoSkipUnavailableTracksSubtitleOn": { + "description": "Subtitle when auto skip on resolve failure is enabled" + }, + "optionsAutoSkipUnavailableTracksSubtitleOff": "Berhenti di lagu yang gagal dan tampilkan pesan error.", + "@optionsAutoSkipUnavailableTracksSubtitleOff": { + "description": "Subtitle when auto skip on resolve failure is disabled" + }, + "optionsInteractionMode": "Mode Interaksi", + "@optionsInteractionMode": { + "description": "Tap behavior mode for track lists" + }, + "modeDownloader": "Mode Downloader", + "@modeDownloader": { + "description": "Interaction mode where taps queue downloads" + }, + "modeDownloaderSubtitle": "Ketuk lagu untuk menambah ke antrean unduhan", + "@modeDownloaderSubtitle": { + "description": "Subtitle for downloader interaction mode" + }, + "modeStreaming": "Mode Streaming", + "@modeStreaming": { + "description": "Interaction mode where taps start playback" + }, + "modeStreamingSubtitle": "Ketuk lagu untuk langsung memutar", + "@modeStreamingSubtitle": { + "description": "Subtitle for streaming interaction mode" + }, + "optionsUseExtensionProviders": "Gunakan Provider Ekstensi", "@optionsUseExtensionProviders": { "description": "Enable extension download providers" }, @@ -1336,11 +1368,15 @@ } } }, - "errorNoTracksFound": "Tidak ada lagu ditemukan", - "@errorNoTracksFound": { - "description": "Error - search returned no results" - }, - "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", + "errorNoTracksFound": "Tidak ada lagu ditemukan", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorSeekNotSupported": "Menggeser posisi lagu tidak didukung untuk live stream ini", + "@errorSeekNotSupported": { + "description": "Error - seek disabled for live decrypted stream" + }, + "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", "@errorMissingExtensionSource": { "description": "Error - extension source not available", "placeholders": { @@ -2013,16 +2049,25 @@ "@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}}", + "downloadAllCount": "Unduh Semua ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "playAllCount": "Putar Semua ({count})", + "@playAllCount": { + "description": "Play all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2927,14 +2972,22 @@ } } }, - "discographyDownload": "Download Discography", - "@discographyDownload": { - "description": "Button - download artist discography" - }, - "discographyDownloadAll": "Unduh Semua", - "@discographyDownloadAll": { - "description": "Option - download entire discography" - }, + "discographyDownload": "Download Discography", + "@discographyDownload": { + "description": "Button - download artist discography" + }, + "discographyPlay": "Putar Diskografi", + "@discographyPlay": { + "description": "Button - play artist discography" + }, + "discographyDownloadAll": "Unduh Semua", + "@discographyDownloadAll": { + "description": "Option - download entire discography" + }, + "discographyPlayAll": "Putar Semua", + "@discographyPlayAll": { + "description": "Option - play entire discography" + }, "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", @@ -3012,10 +3065,14 @@ } } }, - "discographyDownloadSelected": "Download Selected", - "@discographyDownloadSelected": { - "description": "Button - download selected albums" - }, + "discographyDownloadSelected": "Download Selected", + "@discographyDownloadSelected": { + "description": "Button - download selected albums" + }, + "discographyPlaySelected": "Putar Terpilih", + "@discographyPlaySelected": { + "description": "Button - play selected albums" + }, "discographyAddedToQueue": "Added {count} tracks to queue", "@discographyAddedToQueue": { "description": "Snackbar - tracks added from discography", @@ -4132,5 +4189,172 @@ "collectionPlaylistChangeCover": "Ubah gambar sampul", "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, "collectionPlaylistRemoveCover": "Hapus gambar sampul", - "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"} + "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}, + "setupModeSelectionTitle": "Pilih Mode Anda", + "setupModeSelectionDescription": "Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.", + "setupModeDownloaderTitle": "Pengunduh", + "setupModeDownloaderFeature1": "Unduh trek dalam kualitas FLAC lossless", + "setupModeDownloaderFeature2": "Simpan musik ke perangkat Anda untuk mendengarkan offline", + "setupModeDownloaderFeature3": "Kelola perpustakaan musik lokal Anda", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Streaming trek secara instan tanpa mengunduh", + "setupModeStreamingFeature2": "Smart Queue secara otomatis menemukan musik baru untuk Anda", + "setupModeStreamingFeature3": "Putar trek apa pun sesuai permintaan dengan kontrol pemutaran", + "setupModeChangeableLater": "Anda dapat beralih antar mode kapan saja di Pengaturan.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda", + + "selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}", + "@selectionShareCount": { + "description": "Share button text with count in selection mode", + "placeholders": { + "count": {"type": "int"} + } + }, + "selectionShareNoFiles": "Tidak ada file yang dapat dibagikan", + "@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"}, + "selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}", + "@selectionConvertCount": { + "description": "Convert button text with count in selection mode", + "placeholders": { + "count": {"type": "int"} + } + }, + "selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih", + "@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"}, + "selectionBatchConvertConfirmTitle": "Konversi Massal", + "@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"}, + "selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.", + "@selectionBatchConvertConfirmMessage": { + "description": "Confirmation dialog message for batch conversion", + "placeholders": { + "count": {"type": "int"}, + "format": {"type": "String"}, + "bitrate": {"type": "String"} + } + }, + "selectionBatchConvertProgress": "Mengonversi {current} dari {total}...", + "@selectionBatchConvertProgress": { + "description": "Snackbar during batch conversion progress", + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}", + "@selectionBatchConvertSuccess": { + "description": "Snackbar after batch conversion completes", + "placeholders": { + "success": {"type": "int"}, + "total": {"type": "int"}, + "format": {"type": "String"} + } + }, + + "whatsNewTitle": "Yang Baru di 4.0", + "@whatsNewTitle": {"description": "Title for the What's New screen"}, + "whatsNewSubtitle": "SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x", + "@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"}, + "whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0", + "@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"}, + "whatsNewWelcomeDesc": "Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.", + "@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"}, + "whatsNewWelcomeTip1": "Mode streaming baru dengan pemutaran instan", + "@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"}, + "whatsNewWelcomeTip2": "Perpustakaan dan pemutar layar penuh yang didesain ulang", + "@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"}, + "whatsNewWelcomeTip3": "Alat massal, peningkatan performa, dan lainnya", + "@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"}, + "whatsNewStreamingTitle": "Mode Streaming", + "@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"}, + "whatsNewStreamingDesc": "Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.", + "@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"}, + "whatsNewSmartQueueTitle": "Smart Queue", + "@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"}, + "whatsNewSmartQueueDesc": "Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.", + "@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"}, + "whatsNewDualModeTitle": "Mode Ganda", + "@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"}, + "whatsNewDualModeDesc": "Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.", + "@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"}, + "whatsNewLibraryTitle": "Perpustakaan Baru", + "@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"}, + "whatsNewLibraryDesc": "Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.", + "@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"}, + "whatsNewPlayerTitle": "Pemutar Layar Penuh", + "@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"}, + "whatsNewPlayerDesc": "Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.", + "@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"}, + "whatsNewContextMenuTitle": "Menu Tekan Lama", + "@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"}, + "whatsNewContextMenuDesc": "Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.", + "@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"}, + "whatsNewPerformanceTitle": "Performa", + "@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"}, + "whatsNewPerformanceDesc": "Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.", + "@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"}, + "whatsNewBatchToolsTitle": "Alat Massal", + "@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"}, + "whatsNewBatchToolsDesc": "Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.", + "@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"}, + "whatsNewStreamingTip1": "Ketuk trek apa pun untuk langsung memutar", + "@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"}, + "whatsNewStreamingTip2": "Lirik tersinkron di pemutar layar penuh", + "@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"}, + "whatsNewStreamingTip3": "Unduh trek langsung dari pemutar", + "@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"}, + "whatsNewSmartQueueTip1": "Antrean terisi otomatis dengan trek terkait", + "@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"}, + "whatsNewSmartQueueTip2": "Temukan artis baru saat mendengarkan", + "@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"}, + "whatsNewSmartQueueTip3": "Tak pernah kehabisan musik untuk diputar", + "@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"}, + "whatsNewDualModeTip1": "Beralih mode kapan saja di Pengaturan", + "@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"}, + "whatsNewDualModeTip2": "Tombol UI menyesuaikan dengan mode Anda", + "@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"}, + "whatsNewDualModeTip3": "Unduh untuk offline, streaming untuk putar langsung", + "@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"}, + "whatsNewLibraryTip1": "Seret dan lepas untuk mengatur playlist", + "@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"}, + "whatsNewLibraryTip2": "Atur gambar sampul kustom untuk playlist", + "@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"}, + "whatsNewLibraryTip3": "Pilih banyak trek untuk aksi massal", + "@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"}, + "whatsNewPlayerTip1": "Seni sampul dengan efek paralaks", + "@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"}, + "whatsNewPlayerTip2": "Pemutaran tetap tersimpan saat restart", + "@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"}, + "whatsNewPlayerTip3": "Lirik tersinkron saat mendengarkan", + "@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"}, + "whatsNewContextMenuTip1": "Tambahkan trek ke playlist mana pun langsung", + "@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"}, + "whatsNewContextMenuTip2": "Bagikan atau konversi dengan satu ketukan", + "@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"}, + "whatsNewContextMenuTip3": "Perbarui metadata saat diperlukan", + "@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"}, + "whatsNewBatchToolsTip1": "Bagikan banyak trek sekaligus", + "@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"}, + "whatsNewBatchToolsTip2": "Konversi massal ke format MP3 atau Opus", + "@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"}, + "whatsNewBatchToolsTip3": "Perbarui metadata di seluruh perpustakaan", + "@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"}, + "whatsNewPerformanceTip1": "Waktu startup aplikasi lebih cepat", + "@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"}, + "whatsNewPerformanceTip2": "Penggunaan memori berkurang saat pemutaran", + "@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"}, + "whatsNewPerformanceTip3": "Penyimpanan berbasis SQLite untuk keandalan", + "@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"}, + "whatsNewReadyMessage": "Siap — nikmati SpotiFLAC yang baru!", + "@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"}, + "whatsNewGetStarted": "Ayo Mulai", + "@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"}, + "whatsNewPageIndicator": "{current} dari {total}", + "@whatsNewPageIndicator": { + "description": "Page indicator text in What's New screen", + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + } } diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index cef5e33a..12f1259b 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "モードを選択", + "setupModeSelectionDescription": "SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。", + "setupModeDownloaderTitle": "ダウンローダー", + "setupModeDownloaderFeature1": "ロスレスFLAC品質でトラックをダウンロード", + "setupModeDownloaderFeature2": "オフライン再生用に音楽をデバイスに保存", + "setupModeDownloaderFeature3": "ローカル音楽ライブラリを管理", + "setupModeStreamingTitle": "ストリーミング", + "setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング", + "setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます", + "setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生", + "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "類似トラックを自動的に検出してキューに追加" } \ No newline at end of file diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 5627cd07..a350b7a2 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "모드 선택", + "setupModeSelectionDescription": "SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.", + "setupModeDownloaderTitle": "다운로더", + "setupModeDownloaderFeature1": "무손실 FLAC 품질로 트랙 다운로드", + "setupModeDownloaderFeature2": "오프라인 감상을 위해 기기에 음악 저장", + "setupModeDownloaderFeature3": "로컬 음악 라이브러리 관리", + "setupModeStreamingTitle": "스트리밍", + "setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍", + "setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다", + "setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생", + "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "유사한 트랙을 자동으로 검색하여 대기열에 추가" } \ No newline at end of file diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index e331bf27..34ceb20a 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Kies je modus", + "setupModeSelectionDescription": "Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.", + "setupModeDownloaderTitle": "Downloader", + "setupModeDownloaderFeature1": "Download nummers in lossless FLAC-kwaliteit", + "setupModeDownloaderFeature2": "Sla muziek op je apparaat op om offline te luisteren", + "setupModeDownloaderFeature3": "Beheer je lokale muziekbibliotheek", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Stream nummers direct zonder te downloaden", + "setupModeStreamingFeature2": "Smart Queue ontdekt automatisch nieuwe muziek voor je", + "setupModeStreamingFeature3": "Speel elk nummer op aanvraag af met afspeelbediening", + "setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij" } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 654f6c08..391b81c3 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2565,5 +2565,18 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" - } + }, + "setupModeSelectionTitle": "Escolha seu modo", + "setupModeSelectionDescription": "Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.", + "setupModeDownloaderTitle": "Downloader", + "setupModeDownloaderFeature1": "Baixe faixas em qualidade FLAC lossless", + "setupModeDownloaderFeature2": "Salve músicas no seu dispositivo para ouvir offline", + "setupModeDownloaderFeature3": "Gerencie sua biblioteca de músicas local", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem baixar", + "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para você", + "setupModeStreamingFeature3": "Reproduza qualquer faixa sob demanda com controles de reprodução", + "setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila" } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 75810ad5..c3e621f9 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Escolha o seu modo", + "setupModeSelectionDescription": "Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.", + "setupModeDownloaderTitle": "Transferência", + "setupModeDownloaderFeature1": "Transfira faixas em qualidade FLAC sem perdas", + "setupModeDownloaderFeature2": "Guarde música no seu dispositivo para ouvir offline", + "setupModeDownloaderFeature3": "Faça a gestão da sua biblioteca de música local", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem transferir", + "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para si", + "setupModeStreamingFeature3": "Reproduza qualquer faixa a pedido com controlos de reprodução", + "setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila" } \ No newline at end of file diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index f29925ea..818424cf 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Выберите режим", + "setupModeSelectionDescription": "Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.", + "setupModeDownloaderTitle": "Загрузчик", + "setupModeDownloaderFeature1": "Скачивайте треки в качестве FLAC без потерь", + "setupModeDownloaderFeature2": "Сохраняйте музыку на устройство для прослушивания офлайн", + "setupModeDownloaderFeature3": "Управляйте своей локальной музыкальной библиотекой", + "setupModeStreamingTitle": "Стриминг", + "setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания", + "setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас", + "setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления", + "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Автоматически находите и добавляйте похожие треки в очередь воспроизведения" } \ No newline at end of file diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index e51150d8..0b736169 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Modunuzu Seçin", + "setupModeSelectionDescription": "SpotiFLAC'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar'dan değiştirebilirsiniz.", + "setupModeDownloaderTitle": "İndirici", + "setupModeDownloaderFeature1": "Kayıpsız FLAC kalitesinde parça indirin", + "setupModeDownloaderFeature2": "Çevrimdışı dinlemek için müziği cihazınıza kaydedin", + "setupModeDownloaderFeature3": "Yerel müzik kütüphanenizi yönetin", + "setupModeStreamingTitle": "Yayın Akışı", + "setupModeStreamingFeature1": "İndirmeden parçaları anında yayınlayın", + "setupModeStreamingFeature2": "Smart Queue sizin için otomatik olarak yeni müzik keşfeder", + "setupModeStreamingFeature3": "İstediğiniz parçayı oynatma kontrolleriyle çalın", + "setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index aad9e509..bc4c4b4e 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2565,5 +2565,18 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" - } + }, + "setupModeSelectionTitle": "选择您的模式", + "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。", + "setupModeDownloaderTitle": "下载器", + "setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目", + "setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听", + "setupModeDownloaderFeature3": "管理您的本地音乐库", + "setupModeStreamingTitle": "流媒体", + "setupModeStreamingFeature1": "无需下载即可即时播放曲目", + "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", + "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", + "setupModeChangeableLater": "您可以随时在设置中切换模式。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index f55e8b80..6f34877c 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "选择您的模式", + "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。", + "setupModeDownloaderTitle": "下载器", + "setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目", + "setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听", + "setupModeDownloaderFeature3": "管理您的本地音乐库", + "setupModeStreamingTitle": "流媒体", + "setupModeStreamingFeature1": "无需下载即可即时播放曲目", + "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", + "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", + "setupModeChangeableLater": "您可以随时在设置中切换模式。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 954569ce..91fb2903 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "選擇您的模式", + "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。", + "setupModeDownloaderTitle": "下載器", + "setupModeDownloaderFeature1": "以無損 FLAC 品質下載曲目", + "setupModeDownloaderFeature2": "將音樂儲存到裝置以供離線收聽", + "setupModeDownloaderFeature3": "管理您的本機音樂庫", + "setupModeStreamingTitle": "串流", + "setupModeStreamingFeature1": "無需下載即可即時串流曲目", + "setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂", + "setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目", + "setupModeChangeableLater": "您可以隨時在設定中切換模式。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "自動探索並將相似曲目新增到您的佇列中" } \ No newline at end of file diff --git a/lib/models/playback_item.dart b/lib/models/playback_item.dart new file mode 100644 index 00000000..877674d0 --- /dev/null +++ b/lib/models/playback_item.dart @@ -0,0 +1,91 @@ +import 'package:spotiflac_android/models/track.dart'; + +class PlaybackItem { + final String id; + final String title; + final String artist; + final String album; + final String coverUrl; + final String sourceUri; + final bool isLocal; + final String service; + final int durationMs; + + // Stream quality metadata + final String format; + final int bitDepth; + final int sampleRate; + final int bitrate; + + // Original track reference for queue operations + final Track? track; + + const PlaybackItem({ + required this.id, + required this.title, + required this.artist, + this.album = '', + this.coverUrl = '', + required this.sourceUri, + this.isLocal = false, + this.service = '', + this.durationMs = 0, + this.format = '', + this.bitDepth = 0, + this.sampleRate = 0, + this.bitrate = 0, + this.track, + }); + + PlaybackItem copyWith({ + String? sourceUri, + String? service, + String? format, + int? bitDepth, + int? sampleRate, + int? bitrate, + }) { + return PlaybackItem( + id: id, + title: title, + artist: artist, + album: album, + coverUrl: coverUrl, + sourceUri: sourceUri ?? this.sourceUri, + isLocal: isLocal, + service: service ?? this.service, + durationMs: durationMs, + format: format ?? this.format, + bitDepth: bitDepth ?? this.bitDepth, + sampleRate: sampleRate ?? this.sampleRate, + bitrate: bitrate ?? this.bitrate, + track: track, + ); + } + + /// Human-readable quality label for UI display + String get qualityLabel { + final parts = []; + + if (format.isNotEmpty) { + parts.add(format.toUpperCase()); + } + + if (bitDepth > 0 && sampleRate > 0) { + final srKhz = sampleRate >= 1000 + ? '${(sampleRate / 1000).toStringAsFixed(sampleRate % 1000 == 0 ? 0 : 1)}kHz' + : '${sampleRate}Hz'; + parts.add('$bitDepth-bit / $srKhz'); + } else if (bitrate > 0) { + parts.add('${bitrate}kbps'); + } + + return parts.join(' '); + } + + /// Whether this item has cover art that is a local file path + bool get hasLocalCover { + if (coverUrl.isEmpty) return false; + return !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); + } +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index d499bf01..4b4a10b3 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,6 +11,9 @@ class AppSettings { final String storageMode; // 'app' or 'saf' final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; + final bool autoSkipUnavailableTracks; + final bool smartQueueEnabled; // Enable smart curated autoplay queue + final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedLyrics; final bool maxQualityCover; final bool isFirstLaunch; @@ -76,6 +79,10 @@ class AppSettings { final String musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics + // Version upgrade tracking + final String + lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') + const AppSettings({ this.defaultService = 'tidal', this.audioQuality = 'LOSSLESS', @@ -84,6 +91,9 @@ class AppSettings { this.storageMode = 'app', this.downloadTreeUri = '', this.autoFallback = true, + this.autoSkipUnavailableTracks = true, + this.smartQueueEnabled = true, + this.embedMetadata = true, this.embedLyrics = true, this.maxQualityCover = true, this.isFirstLaunch = true, @@ -127,6 +137,7 @@ class AppSettings { // Lyrics providers default order this.lyricsProviders = const [ 'lrclib', + 'spotify_api', 'musixmatch', 'netease', 'apple_music', @@ -136,6 +147,8 @@ class AppSettings { this.lyricsIncludeRomanizationNetease = false, this.lyricsMultiPersonWordByWord = false, this.musixmatchLanguage = '', + // Version upgrade tracking + this.lastSeenVersion = '', }); AppSettings copyWith({ @@ -146,6 +159,9 @@ class AppSettings { String? storageMode, String? downloadTreeUri, bool? autoFallback, + bool? autoSkipUnavailableTracks, + bool? smartQueueEnabled, + bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, bool? isFirstLaunch, @@ -193,6 +209,8 @@ class AppSettings { bool? lyricsIncludeRomanizationNetease, bool? lyricsMultiPersonWordByWord, String? musixmatchLanguage, + // Version upgrade tracking + String? lastSeenVersion, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -202,6 +220,10 @@ class AppSettings { storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, + autoSkipUnavailableTracks: + autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks, + smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled, + embedMetadata: embedMetadata ?? this.embedMetadata, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, @@ -264,6 +286,8 @@ class AppSettings { lyricsMultiPersonWordByWord: lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord, musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, + // Version upgrade tracking + lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index fd02b464..853d34aa 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -14,6 +14,9 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( storageMode: json['storageMode'] as String? ?? 'app', downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, + autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true, + smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true, + embedMetadata: json['embedMetadata'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, @@ -50,10 +53,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any', - networkCompatibilityMode: - json['networkCompatibilityMode'] as bool? ?? - json['songLinkCompatibilityMode'] as bool? ?? - false, + networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false, songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', @@ -64,7 +64,14 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( (json['lyricsProviders'] as List?) ?.map((e) => e as String) .toList() ?? - const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], + const [ + 'lrclib', + 'spotify_api', + 'musixmatch', + 'netease', + 'apple_music', + 'qqmusic', + ], lyricsIncludeTranslationNetease: json['lyricsIncludeTranslationNetease'] as bool? ?? false, lyricsIncludeRomanizationNetease: @@ -72,6 +79,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( lyricsMultiPersonWordByWord: json['lyricsMultiPersonWordByWord'] as bool? ?? false, musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', + lastSeenVersion: json['lastSeenVersion'] as String? ?? '', ); Map _$AppSettingsToJson( @@ -84,6 +92,9 @@ Map _$AppSettingsToJson( 'storageMode': instance.storageMode, 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, + 'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks, + 'smartQueueEnabled': instance.smartQueueEnabled, + 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, @@ -128,4 +139,5 @@ Map _$AppSettingsToJson( 'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease, 'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord, 'musixmatchLanguage': instance.musixmatchLanguage, + 'lastSeenVersion': instance.lastSeenVersion, }; diff --git a/lib/models/track.dart b/lib/models/track.dart index d2ab69fe..244a7a65 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -9,6 +9,8 @@ class Track { final String artistName; final String albumName; final String? albumArtist; + final String? artistId; + final String? albumId; final String? coverUrl; final String? isrc; final int duration; @@ -27,6 +29,8 @@ class Track { required this.artistName, required this.albumName, this.albumArtist, + this.artistId, + this.albumId, this.coverUrl, this.isrc, required this.duration, diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 1d2277b7..f640cfe7 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -12,6 +12,8 @@ Track _$TrackFromJson(Map json) => Track( artistName: json['artistName'] as String, albumName: json['albumName'] as String, albumArtist: json['albumArtist'] as String?, + artistId: json['artistId'] as String?, + albumId: json['albumId'] as String?, coverUrl: json['coverUrl'] as String?, isrc: json['isrc'] as String?, duration: (json['duration'] as num).toInt(), @@ -35,6 +37,8 @@ Map _$TrackToJson(Track instance) => { 'artistName': instance.artistName, 'albumName': instance.albumName, 'albumArtist': instance.albumArtist, + 'artistId': instance.artistId, + 'albumId': instance.albumId, 'coverUrl': instance.coverUrl, 'isrc': instance.isrc, 'duration': instance.duration, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8c3c37a0..9f61f4e2 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -702,10 +702,13 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; + Timer? _progressStreamBootstrapTimer; Timer? _queuePersistDebounce; + StreamSubscription>? _progressStreamSub; int _downloadCount = 0; static const _cleanupInterval = 50; static const _progressPollingInterval = Duration(milliseconds: 800); + static const _idleProgressPollEveryTicks = 3; static const _queueSchedulingInterval = Duration(milliseconds: 250); static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. @@ -718,6 +721,9 @@ class DownloadQueueNotifier extends Notifier { final Set _ensuredDirs = {}; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; + int _idleProgressPollTick = 0; + bool _hasReceivedProgressStreamEvent = false; + bool _usingProgressStream = false; String? _lastServiceTrackName; String? _lastServiceArtistName; int _lastServicePercent = -1; @@ -788,7 +794,11 @@ class DownloadQueueNotifier extends Notifier { ref.onDispose(() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; if (_queuePersistDebounce?.isActive == true) { _queuePersistDebounce?.cancel(); unawaited(_flushQueueToStorage()); @@ -894,213 +904,105 @@ class DownloadQueueNotifier extends Notifier { } void _startMultiProgressPolling() { + _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + _idleProgressPollTick = 0; + + if (Platform.isAndroid || Platform.isIOS) { + _attachDownloadProgressStream(); + return; + } + + _startMultiProgressPollingTimer(); + } + + void _attachDownloadProgressStream() { + _progressStreamSub = PlatformBridge.downloadProgressStream().listen( + (allProgress) { + _hasReceivedProgressStreamEvent = true; + _usingProgressStream = true; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + _processAllDownloadProgress(allProgress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Progress stream processing failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (_usingProgressStream) { + _log.w( + 'Download progress stream failed, fallback to polling: $error', + ); + } + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _startMultiProgressPollingTimer(); + }, + cancelOnError: false, + ); + + _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Download progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startMultiProgressPollingTimer(); + }); + } + + void _startMultiProgressPollingTimer() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async { if (_isProgressPollingInFlight) return; _isProgressPollingInFlight = true; try { - final allProgress = await PlatformBridge.getAllDownloadProgress(); - final items = allProgress['items'] as Map? ?? {}; final currentItems = state.items; - final itemsById = {}; - final itemIndexById = {}; - int queuedCount = 0; - int downloadingCount = 0; - DownloadItem? firstDownloading; - for (int i = 0; i < currentItems.length; i++) { - final item = currentItems[i]; - itemsById[item.id] = item; - itemIndexById[item.id] = i; - if (item.status == DownloadStatus.downloading) { - downloadingCount++; - firstDownloading ??= item; - } - if (item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading) { - queuedCount++; - } - } - final progressUpdates = {}; + final hasQueuedItems = currentItems.any( + (item) => item.status == DownloadStatus.queued, + ); + final hasActiveItems = currentItems.any( + (item) => + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ); - bool hasFinalizingItem = false; - String? finalizingTrackName; - String? finalizingArtistName; - - for (final entry in items.entries) { - final itemId = entry.key; - final localItem = itemsById[itemId]; - if (localItem == null) { - continue; - } - if (localItem.status == DownloadStatus.skipped) { - PlatformBridge.clearItemProgress(itemId).catchError((_) {}); - continue; - } - if (localItem.status == DownloadStatus.completed || - localItem.status == DownloadStatus.failed) { - continue; - } - final itemProgress = entry.value as Map; - final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; - final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; - final speedMBps = - (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; - final isDownloading = - itemProgress['is_downloading'] as bool? ?? false; - final status = itemProgress['status'] as String? ?? 'downloading'; - - if (status == 'finalizing' && bytesTotal > 0) { - progressUpdates[itemId] = const _ProgressUpdate( - status: DownloadStatus.finalizing, - progress: 1.0, - ); - hasFinalizingItem = true; - finalizingTrackName = localItem.track.name; - finalizingArtistName = localItem.track.artistName; - continue; + if (!hasActiveItems) { + if (state.isPaused || !hasQueuedItems) { + _idleProgressPollTick = 0; + return; } - final progressFromBackend = - (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; - - if (isDownloading) { - double percentage = 0.0; - if (bytesTotal > 0) { - percentage = bytesReceived / bytesTotal; - } else { - percentage = progressFromBackend; - } - final normalizedProgress = _normalizeProgressForUi(percentage); - final normalizedSpeed = _normalizeSpeedForUi(speedMBps); - final normalizedBytes = _normalizeBytesForUi(bytesReceived); - - progressUpdates[itemId] = _ProgressUpdate( - status: DownloadStatus.downloading, - progress: normalizedProgress, - speedMBps: normalizedSpeed, - bytesReceived: normalizedBytes, - ); - - if (LogBuffer.loggingEnabled) { - final mbReceived = bytesReceived / (1024 * 1024); - final mbTotal = bytesTotal / (1024 * 1024); - if (bytesTotal > 0) { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } else { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } - } + _idleProgressPollTick = + (_idleProgressPollTick + 1) % _idleProgressPollEveryTicks; + if (_idleProgressPollTick != 0) { + return; } + } else { + _idleProgressPollTick = 0; } - if (progressUpdates.isNotEmpty) { - var updatedItems = currentItems; - bool changed = false; - - for (final entry in progressUpdates.entries) { - final index = itemIndexById[entry.key]; - if (index == null) continue; - final current = updatedItems[index]; - if (current.status == DownloadStatus.skipped || - current.status == DownloadStatus.completed || - current.status == DownloadStatus.failed) { - continue; - } - final update = entry.value; - final next = current.copyWith( - status: update.status, - progress: update.progress, - speedMBps: update.speedMBps ?? current.speedMBps, - bytesReceived: update.bytesReceived ?? current.bytesReceived, - ); - if (current.status != next.status || - current.progress != next.progress || - current.speedMBps != next.speedMBps || - current.bytesReceived != next.bytesReceived) { - if (!changed) { - updatedItems = List.from(updatedItems); - changed = true; - } - updatedItems[index] = next; - } - } - - if (changed) { - state = state.copyWith(items: updatedItems); - } - } - - if (hasFinalizingItem && finalizingTrackName != null) { - final safeArtistName = finalizingArtistName ?? ''; - if (finalizingTrackName != _lastFinalizingTrackName || - safeArtistName != _lastFinalizingArtistName) { - _notificationService.showDownloadFinalizing( - trackName: finalizingTrackName, - artistName: safeArtistName, - ); - _lastFinalizingTrackName = finalizingTrackName; - _lastFinalizingArtistName = safeArtistName; - } - return; - } - _lastFinalizingTrackName = null; - _lastFinalizingArtistName = null; - - if (items.isNotEmpty) { - final firstEntry = items.entries.first; - final firstProgress = firstEntry.value as Map; - final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; - final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; - - if (downloadingCount > 0 && firstDownloading != null) { - final trackName = downloadingCount == 1 - ? firstDownloading.track.name - : '$downloadingCount downloads'; - final artistName = downloadingCount == 1 - ? firstDownloading.track.artistName - : 'Downloading...'; - - int notifProgress = bytesReceived; - int notifTotal = bytesTotal; - - if (bytesTotal <= 0) { - final progressPercent = - (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; - notifProgress = (progressPercent * 100).toInt(); - notifTotal = 100; - } - - final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; - if (_shouldUpdateProgressNotification( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - queueCount: queuedCount, - )) { - _notificationService.showDownloadProgress( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - ); - } - - if (Platform.isAndroid) { - _maybeUpdateAndroidDownloadService( - trackName: firstDownloading.track.name, - artistName: firstDownloading.track.artistName, - progress: notifProgress, - total: safeNotifTotal, - queueCount: queuedCount, - ); - } - } - } + final allProgress = await PlatformBridge.getAllDownloadProgress(); + _processAllDownloadProgress(allProgress); _progressPollingErrorCount = 0; } catch (e) { _progressPollingErrorCount++; @@ -1113,6 +1015,221 @@ class DownloadQueueNotifier extends Notifier { }); } + void _processAllDownloadProgress(Map allProgress) { + final rawItems = allProgress['items']; + final items = rawItems is Map + ? rawItems.map((key, value) => MapEntry(key.toString(), value)) + : const {}; + final currentItems = state.items; + final itemsById = {}; + final itemIndexById = {}; + int queuedCount = 0; + int downloadingCount = 0; + DownloadItem? firstDownloading; + for (int i = 0; i < currentItems.length; i++) { + final item = currentItems[i]; + itemsById[item.id] = item; + itemIndexById[item.id] = i; + if (item.status == DownloadStatus.downloading) { + downloadingCount++; + firstDownloading ??= item; + } + if (item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading) { + queuedCount++; + } + } + final progressUpdates = {}; + + bool hasFinalizingItem = false; + String? finalizingTrackName; + String? finalizingArtistName; + + for (final entry in items.entries) { + final itemId = entry.key; + final localItem = itemsById[itemId]; + if (localItem == null) { + continue; + } + if (localItem.status == DownloadStatus.skipped) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } + if (localItem.status == DownloadStatus.completed || + localItem.status == DownloadStatus.failed) { + continue; + } + final rawItemProgress = entry.value; + if (rawItemProgress is! Map) { + continue; + } + final itemProgress = Map.from(rawItemProgress); + final bytesReceived = + (itemProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0; + final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; + final isDownloading = itemProgress['is_downloading'] as bool? ?? false; + final status = itemProgress['status'] as String? ?? 'downloading'; + + if (status == 'finalizing' && bytesTotal > 0) { + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; + continue; + } + + final progressFromBackend = + (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; + + if (isDownloading) { + double percentage = 0.0; + if (bytesTotal > 0) { + percentage = bytesReceived / bytesTotal; + } else { + percentage = progressFromBackend; + } + final normalizedProgress = _normalizeProgressForUi(percentage); + final normalizedSpeed = _normalizeSpeedForUi(speedMBps); + final normalizedBytes = _normalizeBytesForUi(bytesReceived); + + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: normalizedProgress, + speedMBps: normalizedSpeed, + bytesReceived: normalizedBytes, + ); + + if (LogBuffer.loggingEnabled) { + final mbReceived = bytesReceived / (1024 * 1024); + final mbTotal = bytesTotal / (1024 * 1024); + if (bytesTotal > 0) { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } else { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } + } + } + } + + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + + for (final entry in progressUpdates.entries) { + final index = itemIndexById[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + bytesReceived: update.bytesReceived ?? current.bytesReceived, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps || + current.bytesReceived != next.bytesReceived) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + + if (hasFinalizingItem && finalizingTrackName != null) { + final safeArtistName = finalizingArtistName ?? ''; + if (finalizingTrackName != _lastFinalizingTrackName || + safeArtistName != _lastFinalizingArtistName) { + _notificationService.showDownloadFinalizing( + trackName: finalizingTrackName, + artistName: safeArtistName, + ); + _lastFinalizingTrackName = finalizingTrackName; + _lastFinalizingArtistName = safeArtistName; + } + return; + } + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; + + if (items.isNotEmpty) { + final firstEntry = items.entries.first; + final rawFirstProgress = firstEntry.value; + if (rawFirstProgress is! Map) { + return; + } + final firstProgress = Map.from(rawFirstProgress); + final bytesReceived = + (firstProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0; + + if (downloadingCount > 0 && firstDownloading != null) { + final trackName = downloadingCount == 1 + ? firstDownloading.track.name + : '$downloadingCount downloads'; + final artistName = downloadingCount == 1 + ? firstDownloading.track.artistName + : 'Downloading...'; + + int notifProgress = bytesReceived; + int notifTotal = bytesTotal; + + if (bytesTotal <= 0) { + final progressPercent = + (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; + notifProgress = (progressPercent * 100).toInt(); + notifTotal = 100; + } + + final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; + if (_shouldUpdateProgressNotification( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + queueCount: queuedCount, + )) { + _notificationService.showDownloadProgress( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + ); + } + + if (Platform.isAndroid) { + _maybeUpdateAndroidDownloadService( + trackName: firstDownloading.track.name, + artistName: firstDownloading.track.artistName, + progress: notifProgress, + total: safeNotifTotal, + queueCount: queuedCount, + ); + } + } + } + } + void _maybeUpdateAndroidDownloadService({ required String trackName, required String artistName, @@ -1156,9 +1273,16 @@ class DownloadQueueNotifier extends Notifier { void _stopProgressPolling() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; _progressPollingErrorCount = 0; _isProgressPollingInFlight = false; + _idleProgressPollTick = 0; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; _lastServiceTrackName = null; _lastServiceArtistName = null; _lastServicePercent = -1; @@ -1926,6 +2050,8 @@ class DownloadQueueNotifier extends Notifier { artistName: baseTrack.artistName, albumName: backendAlbum ?? baseTrack.albumName, albumArtist: resolvedAlbumArtist, + artistId: baseTrack.artistId, + albumId: baseTrack.albumId, coverUrl: baseTrack.coverUrl, duration: baseTrack.duration, isrc: baseTrack.isrc, @@ -1945,8 +2071,13 @@ class DownloadQueueNotifier extends Notifier { String? genre, String? label, String? copyright, + bool writeExternalLrc = true, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping FLAC metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2030,12 +2161,17 @@ class DownloadQueueNotifier extends Notifier { final shouldEmbedLyrics = settings.embedLyrics && (lyricsMode == 'embed' || lyricsMode == 'both'); + final shouldSaveExternalLyrics = + settings.embedLyrics && + (lyricsMode == 'external' || lyricsMode == 'both'); + final shouldFetchLyrics = shouldEmbedLyrics || shouldSaveExternalLyrics; + String? lrcContent; - if (shouldEmbedLyrics) { + if (shouldFetchLyrics) { try { final durationMs = track.duration * 1000; - final lrcContent = await PlatformBridge.getLyricsLRC( + final fetchedLrc = await PlatformBridge.getLyricsLRC( track.id, track.name, track.artistName, @@ -2043,20 +2179,46 @@ class DownloadQueueNotifier extends Notifier { durationMs: durationMs, ); - if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { - metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); - } else if (lrcContent == '[instrumental:true]') { - _log.d('Track is instrumental, skipping lyrics embedding'); + if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') { + lrcContent = fetchedLrc; + _log.d('Lyrics fetched for FLAC (${fetchedLrc.length} chars)'); + } else if (fetchedLrc == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics handling'); + } else { + _log.d('No lyrics returned for FLAC download'); } } catch (e) { - _log.w('Failed to fetch lyrics for embedding: $e'); + _log.w('Failed to fetch lyrics for FLAC: $e'); + } + } + + if (shouldEmbedLyrics) { + if (lrcContent != null) { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics added to FLAC metadata'); + } else { + _log.d('No lyrics available for FLAC embedding'); } } else { metadata['LYRICS'] = ''; metadata['UNSYNCEDLYRICS'] = ''; - _log.d('Lyrics embedding disabled by settings, skipping lyric fetch'); + _log.d( + 'Lyrics embedding disabled by settings, skipping lyric embedding', + ); + } + + if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { + try { + final replacedPath = flacPath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); + final lrcPath = replacedPath == flacPath + ? '$flacPath.lrc' + : replacedPath; + await File(lrcPath).writeAsString(lrcContent); + _log.d('External LRC file saved: $lrcPath'); + } catch (e) { + _log.w('Failed to save external LRC file for FLAC: $e'); + } } _log.d('Generating tags for FLAC: $metadata'); @@ -2098,6 +2260,10 @@ class DownloadQueueNotifier extends Notifier { String? copyright, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping MP3 metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2262,6 +2428,10 @@ class DownloadQueueNotifier extends Notifier { String? copyright, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping Opus metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2743,6 +2913,7 @@ class DownloadQueueNotifier extends Notifier { try { final settings = ref.read(settingsProvider); + final metadataEmbeddingEnabled = settings.embedMetadata; Track trackToDownload = item.track; final needsEnrichment = @@ -2785,6 +2956,11 @@ class DownloadQueueNotifier extends Notifier { (data['album_name'] as String?) ?? trackToDownload.albumName, albumArtist: data['album_artist'] as String?, + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + trackToDownload.artistId, + albumId: + data['album_id']?.toString() ?? trackToDownload.albumId, coverUrl: data['images'] as String?, duration: ((data['duration_ms'] as int?) ?? @@ -2991,6 +3167,8 @@ class DownloadQueueNotifier extends Notifier { artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, albumArtist: trackToDownload.albumArtist, + artistId: trackToDownload.artistId, + albumId: trackToDownload.albumId, coverUrl: trackToDownload.coverUrl, duration: trackToDownload.duration, isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) @@ -3101,12 +3279,16 @@ class DownloadQueueNotifier extends Notifier { artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, albumArtist: resolvedAlbumArtist, - coverUrl: trackToDownload.coverUrl ?? '', + coverUrl: metadataEmbeddingEnabled + ? (trackToDownload.coverUrl ?? '') + : '', outputDir: outputDir, filenameFormat: state.filenameFormat, quality: quality, - embedLyrics: settings.embedLyrics, - embedMaxQualityCover: settings.maxQualityCover, + embedMetadata: metadataEmbeddingEnabled, + embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics, + embedMaxQualityCover: + metadataEmbeddingEnabled && settings.maxQualityCover, trackNumber: normalizedTrackNumber, discNumber: normalizedDiscNumber, releaseDate: trackToDownload.releaseDate ?? '', @@ -3501,6 +3683,7 @@ class DownloadQueueNotifier extends Notifier { genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, ); final newFileName = '${safBaseName ?? 'track'}.flac'; @@ -3692,7 +3875,8 @@ class DownloadQueueNotifier extends Notifier { } } } - } else if (isContentUriPath && + } else if (metadataEmbeddingEnabled && + isContentUriPath && effectiveSafMode && isFlacFile && !wasExisting) { @@ -3724,6 +3908,7 @@ class DownloadQueueNotifier extends Notifier { genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, ); final newFileName = '${safBaseName ?? 'track'}.flac'; @@ -3753,7 +3938,8 @@ class DownloadQueueNotifier extends Notifier { } catch (_) {} } } - } else if (!isContentUriPath && + } else if (metadataEmbeddingEnabled && + !isContentUriPath && !effectiveSafMode && isFlacFile && !wasExisting && @@ -3792,7 +3978,10 @@ class DownloadQueueNotifier extends Notifier { } // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt - if (!wasExisting && item.service == 'youtube' && filePath != null) { + if (metadataEmbeddingEnabled && + !wasExisting && + item.service == 'youtube' && + filePath != null) { final isOpusFile = filePath.endsWith('.opus'); final isMp3File = filePath.endsWith('.mp3'); @@ -3957,6 +4146,7 @@ class DownloadQueueNotifier extends Notifier { final lyricsMode = settings.lyricsMode; final shouldSaveExternalLrc = + metadataEmbeddingEnabled && settings.embedLyrics && (lyricsMode == 'external' || lyricsMode == 'both'); if (shouldSaveExternalLrc && diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 31722a7b..1963e4cc 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -1,5 +1,9 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -9,6 +13,7 @@ final _log = AppLogger('ExtensionProvider'); const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; +const _spotifyWebExtensionId = 'spotify-web'; class Extension { final String id; @@ -27,12 +32,14 @@ class Extension { final bool hasMetadataProvider; final bool hasDownloadProvider; final bool hasLyricsProvider; - final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final bool + skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final SearchBehavior? searchBehavior; final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; - final Map capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) + final Map + capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) const Extension({ required this.id, @@ -63,7 +70,8 @@ class Extension { return Extension( id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', - displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + displayName: + json['display_name'] as String? ?? json['name'] as String? ?? '', version: json['version'] as String? ?? '0.0.0', author: json['author'] as String? ?? 'Unknown', description: json['description'] as String? ?? '', @@ -71,28 +79,40 @@ class Extension { status: json['status'] as String? ?? 'loaded', errorMessage: json['error_message'] as String?, iconPath: json['icon_path'] as String?, - permissions: (json['permissions'] as List?)?.cast() ?? [], - settings: (json['settings'] as List?) - ?.map((s) => ExtensionSetting.fromJson(s as Map)) - .toList() ?? [], - qualityOptions: (json['quality_options'] as List?) - ?.map((q) => QualityOption.fromJson(q as Map)) - .toList() ?? [], + permissions: + (json['permissions'] as List?)?.cast() ?? [], + settings: + (json['settings'] as List?) + ?.map((s) => ExtensionSetting.fromJson(s as Map)) + .toList() ?? + [], + qualityOptions: + (json['quality_options'] as List?) + ?.map((q) => QualityOption.fromJson(q as Map)) + .toList() ?? + [], hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, hasDownloadProvider: json['has_download_provider'] as bool? ?? false, hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, - skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false, - searchBehavior: json['search_behavior'] != null - ? SearchBehavior.fromJson(json['search_behavior'] as Map) + skipMetadataEnrichment: + json['skip_metadata_enrichment'] as bool? ?? false, + searchBehavior: json['search_behavior'] != null + ? SearchBehavior.fromJson( + json['search_behavior'] as Map, + ) : null, urlHandler: json['url_handler'] != null ? URLHandler.fromJson(json['url_handler'] as Map) : null, trackMatching: json['track_matching'] != null - ? TrackMatching.fromJson(json['track_matching'] as Map) + ? TrackMatching.fromJson( + json['track_matching'] as Map, + ) : null, postProcessing: json['post_processing'] != null - ? PostProcessing.fromJson(json['post_processing'] as Map) + ? PostProcessing.fromJson( + json['post_processing'] as Map, + ) : null, capabilities: (json['capabilities'] as Map?) ?? const {}, ); @@ -139,7 +159,8 @@ class Extension { hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, - skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, + skipMetadataEnrichment: + skipMetadataEnrichment ?? this.skipMetadataEnrichment, searchBehavior: searchBehavior ?? this.searchBehavior, urlHandler: urlHandler ?? this.urlHandler, trackMatching: trackMatching ?? this.trackMatching, @@ -161,11 +182,7 @@ class SearchFilter { final String? label; final String? icon; - const SearchFilter({ - required this.id, - this.label, - this.icon, - }); + const SearchFilter({required this.id, this.label, this.icon}); factory SearchFilter.fromJson(Map json) { return SearchFilter( @@ -181,10 +198,12 @@ class SearchBehavior { final String? placeholder; final bool primary; final String? icon; - final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final String? + thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) final int? thumbnailWidth; final int? thumbnailHeight; - final List filters; // Available search filters (e.g., track, album, artist, playlist) + final List + filters; // Available search filters (e.g., track, album, artist, playlist) const SearchBehavior({ required this.enabled, @@ -206,9 +225,11 @@ class SearchBehavior { thumbnailRatio: json['thumbnailRatio'] as String?, thumbnailWidth: json['thumbnailWidth'] as int?, thumbnailHeight: json['thumbnailHeight'] as int?, - filters: (json['filters'] as List?) - ?.map((f) => SearchFilter.fromJson(f as Map)) - .toList() ?? [], + filters: + (json['filters'] as List?) + ?.map((f) => SearchFilter.fromJson(f as Map)) + .toList() ?? + [], ); } @@ -216,7 +237,7 @@ class SearchBehavior { if (thumbnailWidth != null && thumbnailHeight != null) { return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); } - + switch (thumbnailRatio) { case 'wide': // 16:9 - YouTube style return (defaultSize * 16 / 9, defaultSize); @@ -253,17 +274,18 @@ class PostProcessing { final bool enabled; final List hooks; - const PostProcessing({ - required this.enabled, - this.hooks = const [], - }); + const PostProcessing({required this.enabled, this.hooks = const []}); factory PostProcessing.fromJson(Map json) { return PostProcessing( enabled: json['enabled'] as bool? ?? false, - hooks: (json['hooks'] as List?) - ?.map((h) => PostProcessingHook.fromJson(h as Map)) - .toList() ?? [], + hooks: + (json['hooks'] as List?) + ?.map( + (h) => PostProcessingHook.fromJson(h as Map), + ) + .toList() ?? + [], ); } } @@ -273,10 +295,7 @@ class URLHandler { final bool enabled; final List patterns; - const URLHandler({ - required this.enabled, - this.patterns = const [], - }); + const URLHandler({required this.enabled, this.patterns = const []}); factory URLHandler.fromJson(Map json) { return URLHandler( @@ -319,7 +338,8 @@ class PostProcessingHook { name: json['name'] as String? ?? '', description: json['description'] as String?, defaultEnabled: json['defaultEnabled'] as bool? ?? false, - supportedFormats: (json['supportedFormats'] as List?)?.cast() ?? [], + supportedFormats: + (json['supportedFormats'] as List?)?.cast() ?? [], ); } } @@ -342,9 +362,14 @@ class QualityOption { id: json['id'] as String? ?? '', label: json['label'] as String? ?? '', description: json['description'] as String?, - settings: (json['settings'] as List?) - ?.map((s) => QualitySpecificSetting.fromJson(s as Map)) - .toList() ?? [], + settings: + (json['settings'] as List?) + ?.map( + (s) => + QualitySpecificSetting.fromJson(s as Map), + ) + .toList() ?? + [], ); } } @@ -447,7 +472,8 @@ class ExtensionState { return ExtensionState( extensions: extensions ?? this.extensions, providerPriority: providerPriority ?? this.providerPriority, - metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority, + metadataProviderPriority: + metadataProviderPriority ?? this.metadataProviderPriority, isLoading: isLoading ?? this.isLoading, error: error, isInitialized: isInitialized ?? this.isInitialized, @@ -455,18 +481,44 @@ class ExtensionState { } } - class ExtensionNotifier extends Notifier { + AppLifecycleListener? _appLifecycleListener; + bool _cleanupInFlight = false; + @override ExtensionState build() { + _appLifecycleListener ??= AppLifecycleListener( + onDetach: _scheduleLifecycleCleanup, + ); + ref.onDispose(() { + _appLifecycleListener?.dispose(); + _appLifecycleListener = null; + }); return const ExtensionState(); } + void _scheduleLifecycleCleanup() { + if (_cleanupInFlight) return; + _cleanupInFlight = true; + unawaited(_cleanupExtensions(reason: 'lifecycle detach')); + } + + Future _cleanupExtensions({required String reason}) async { + try { + await PlatformBridge.cleanupExtensions(); + _log.d('Extensions cleaned up ($reason)'); + } catch (e) { + _log.w('Extension cleanup failed ($reason): $e'); + } finally { + _cleanupInFlight = false; + } + } + Future initialize(String extensionsDir, String dataDir) async { if (state.isInitialized) return; - + state = state.copyWith(isLoading: true, error: null); - + try { await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await loadExtensions(extensionsDir); @@ -482,7 +534,7 @@ class ExtensionNotifier extends Notifier { Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.loadExtensionsFromDir(dirPath); _log.d('Load extensions result: $result'); @@ -500,10 +552,12 @@ class ExtensionNotifier extends Notifier { final extensions = list.map((e) => Extension.fromJson(e)).toList(); state = state.copyWith(extensions: extensions); _log.d('Loaded ${extensions.length} extensions'); - + for (final ext in extensions) { if (ext.searchBehavior != null) { - _log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}'); + _log.d( + 'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}', + ); } } } catch (e) { @@ -512,14 +566,13 @@ class ExtensionNotifier extends Notifier { } } - void clearError() { state = state.copyWith(error: null); } Future installExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.loadExtensionFromPath(filePath); _log.i('Installed extension: ${result['name']}'); @@ -544,10 +597,12 @@ class ExtensionNotifier extends Notifier { Future upgradeExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.upgradeExtension(filePath); - _log.i('Upgraded extension: ${result['display_name']} to v${result['version']}'); + _log.i( + 'Upgraded extension: ${result['display_name']} to v${result['version']}', + ); await refreshExtensions(); state = state.copyWith(isLoading: false); return true; @@ -560,7 +615,7 @@ class ExtensionNotifier extends Notifier { Future removeExtension(String extensionId) async { state = state.copyWith(isLoading: true, error: null); - + try { await PlatformBridge.removeExtension(extensionId); _log.i('Removed extension: $extensionId'); @@ -574,35 +629,40 @@ class ExtensionNotifier extends Notifier { } } - Future setExtensionEnabled(String extensionId, bool enabled) async { try { await PlatformBridge.setExtensionEnabled(extensionId, enabled); _log.d('Set extension $extensionId enabled: $enabled'); - - final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull; - + + final ext = state.extensions + .where((e) => e.id == extensionId) + .firstOrNull; + final extensions = state.extensions.map((e) { if (e.id == extensionId) { return e.copyWith(enabled: enabled); } return e; }).toList(); - + state = state.copyWith(extensions: extensions); - + if (!enabled && ext != null) { final settings = ref.read(settingsProvider); - + if (settings.searchProvider == extensionId) { ref.read(settingsProvider.notifier).setSearchProvider(null); ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - _log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled'); + _log.d( + 'Cleared search provider and reset to Deezer because extension $extensionId was disabled', + ); } - + if (ext.hasDownloadProvider && settings.defaultService == extensionId) { ref.read(settingsProvider.notifier).setDefaultService('tidal'); - _log.d('Reset default service to Tidal because extension $extensionId was disabled'); + _log.d( + 'Reset default service to Tidal because extension $extensionId was disabled', + ); } } } catch (e) { @@ -611,6 +671,68 @@ class ExtensionNotifier extends Notifier { } } + Future ensureSpotifyWebExtensionReady({ + bool setAsSearchProvider = true, + }) async { + try { + await refreshExtensions(); + + var ext = state.extensions + .where((e) => e.id == _spotifyWebExtensionId) + .firstOrNull; + + if (ext == null) { + final cacheDir = await getTemporaryDirectory(); + await PlatformBridge.initExtensionStore(cacheDir.path); + + final tempRoot = await getTemporaryDirectory(); + final installDir = await Directory( + '${tempRoot.path}/spotiflac_bootstrap_spotify_web', + ).create(recursive: true); + + final downloadPath = await PlatformBridge.downloadStoreExtension( + _spotifyWebExtensionId, + installDir.path, + ); + + final installed = await installExtension(downloadPath); + if (!installed) { + _log.w('Failed to install spotify-web extension from store'); + return false; + } + + await refreshExtensions(); + ext = state.extensions + .where((e) => e.id == _spotifyWebExtensionId) + .firstOrNull; + } + + if (ext == null) { + _log.w('spotify-web extension is still not available after install'); + return false; + } + + if (!ext.enabled) { + await setExtensionEnabled(_spotifyWebExtensionId, true); + } + + if (setAsSearchProvider) { + final settings = ref.read(settingsProvider); + if (settings.searchProvider != _spotifyWebExtensionId) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(_spotifyWebExtensionId); + } + } + + _log.i('spotify-web extension is ready'); + return true; + } catch (e) { + _log.w('Failed to ensure spotify-web extension is ready: $e'); + return false; + } + } + Future> getExtensionSettings(String extensionId) async { try { return await PlatformBridge.getExtensionSettings(extensionId); @@ -620,7 +742,10 @@ class ExtensionNotifier extends Notifier { } } - Future setExtensionSettings(String extensionId, Map settings) async { + Future setExtensionSettings( + String extensionId, + Map settings, + ) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); _log.d('Updated settings for extension: $extensionId'); @@ -635,49 +760,72 @@ class ExtensionNotifier extends Notifier { // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_providerPriorityKey); - + List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; priority = saved.map((e) => e as String).toList(); + priority = _sanitizeDownloadProviderPriority(priority); _log.d('Loaded provider priority from prefs: $priority'); + await prefs.setString(_providerPriorityKey, jsonEncode(priority)); // Sync to Go backend await PlatformBridge.setProviderPriority(priority); } else { // Fallback to Go backend default priority = await PlatformBridge.getProviderPriority(); + priority = _sanitizeDownloadProviderPriority(priority); + await PlatformBridge.setProviderPriority(priority); _log.d('Using default provider priority: $priority'); } - + state = state.copyWith(providerPriority: priority); } catch (e) { _log.e('Failed to load provider priority: $e'); } } - Future setProviderPriority(List priority) async { try { + final sanitized = _sanitizeDownloadProviderPriority(priority); // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - + await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); + // Sync to Go backend - await PlatformBridge.setProviderPriority(priority); - state = state.copyWith(providerPriority: priority); - _log.d('Saved provider priority: $priority'); + await PlatformBridge.setProviderPriority(sanitized); + state = state.copyWith(providerPriority: sanitized); + _log.d('Saved provider priority: $sanitized'); } catch (e) { _log.e('Failed to set provider priority: $e'); state = state.copyWith(error: e.toString()); } } + List _sanitizeDownloadProviderPriority(List input) { + final allowed = getAllDownloadProviders().toSet(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + for (final provider in const ['tidal', 'qobuz', 'amazon']) { + if (!result.contains(provider)) { + result.add(provider); + } + } + + return result; + } + Future loadMetadataProviderPriority() async { try { // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_metadataProviderPriorityKey); - + List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; @@ -690,7 +838,7 @@ class ExtensionNotifier extends Notifier { priority = await PlatformBridge.getMetadataProviderPriority(); _log.d('Using default metadata provider priority: $priority'); } - + state = state.copyWith(metadataProviderPriority: priority); } catch (e) { _log.e('Failed to load metadata provider priority: $e'); @@ -702,7 +850,7 @@ class ExtensionNotifier extends Notifier { // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority)); - + // Sync to Go backend await PlatformBridge.setMetadataProviderPriority(priority); state = state.copyWith(metadataProviderPriority: priority); @@ -714,12 +862,9 @@ class ExtensionNotifier extends Notifier { } Future cleanup() async { - try { - await PlatformBridge.cleanupExtensions(); - _log.d('Extensions cleaned up'); - } catch (e) { - _log.e('Failed to cleanup extensions: $e'); - } + if (_cleanupInFlight) return; + _cleanupInFlight = true; + await _cleanupExtensions(reason: 'manual'); } Extension? getExtension(String extensionId) { @@ -755,7 +900,9 @@ class ExtensionNotifier extends Notifier { } List get searchProviders { - return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); + return state.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); } } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 2f4ce8f6..30e8500b 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -121,15 +121,25 @@ class LocalLibraryNotifier extends Notifier { final NotificationService _notificationService = NotificationService(); static const _progressPollingInterval = Duration(milliseconds: 800); Timer? _progressTimer; + Timer? _progressStreamBootstrapTimer; + StreamSubscription>? _progressStreamSub; bool _isLoaded = false; bool _scanCancelRequested = false; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; + bool _hasReceivedProgressStreamEvent = false; + bool _usingProgressStream = false; + static const _scanNotificationHeartbeat = Duration(seconds: 4); + int _lastScanNotificationPercent = -1; + int _lastScanNotificationTotalFiles = -1; + DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); @override LocalLibraryState build() { ref.onDispose(() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); }); Future.microtask(() async { @@ -257,12 +267,19 @@ class LocalLibraryNotifier extends Notifier { scanErrorCount: 0, scanWasCancelled: false, ); - await _showScanProgressNotification( + _resetScanNotificationTracking(); + if (_shouldShowScanProgressNotification( progress: 0, - scannedFiles: 0, totalFiles: 0, - currentFile: null, - ); + isComplete: false, + )) { + await _showScanProgressNotification( + progress: 0, + scannedFiles: 0, + totalFiles: 0, + currentFile: null, + ); + } try { final appSupportDir = await getApplicationSupportDirectory(); @@ -499,49 +516,75 @@ class LocalLibraryNotifier extends Notifier { } void _startProgressPolling() { + _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + + if (Platform.isAndroid || Platform.isIOS) { + _progressStreamSub = PlatformBridge.libraryScanProgressStream().listen( + (progress) async { + _hasReceivedProgressStreamEvent = true; + _usingProgressStream = true; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + await _handleLibraryScanProgress(progress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Library scan progress stream processing failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (_usingProgressStream) { + _log.w( + 'Library scan progress stream failed, fallback to polling: $error', + ); + } + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _startProgressPollingTimer(); + }, + cancelOnError: false, + ); + + _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Library scan progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startProgressPollingTimer(); + }); + return; + } + + _startProgressPollingTimer(); + } + + void _startProgressPollingTimer() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (_) async { if (_isProgressPollingInFlight) return; _isProgressPollingInFlight = true; try { final progress = await PlatformBridge.getLibraryScanProgress(); - final nextProgress = - (progress['progress_pct'] as num?)?.toDouble() ?? 0; - final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( - 0.0, - 100.0, - ); - final currentFile = progress['current_file'] as String?; - final totalFiles = progress['total_files'] as int? ?? 0; - final scannedFiles = progress['scanned_files'] as int? ?? 0; - final errorCount = progress['error_count'] as int? ?? 0; - - final shouldUpdateState = - state.scanProgress != normalizedProgress || - state.scanCurrentFile != currentFile || - state.scanTotalFiles != totalFiles || - state.scannedFiles != scannedFiles || - state.scanErrorCount != errorCount; - - if (shouldUpdateState) { - state = state.copyWith( - scanProgress: normalizedProgress, - scanCurrentFile: currentFile, - scanTotalFiles: totalFiles, - scannedFiles: scannedFiles, - scanErrorCount: errorCount, - ); - await _showScanProgressNotification( - progress: normalizedProgress, - scannedFiles: scannedFiles, - totalFiles: totalFiles, - currentFile: currentFile, - ); - } - - if (progress['is_complete'] == true) { - _stopProgressPolling(); - } + await _handleLibraryScanProgress(progress); _progressPollingErrorCount = 0; } catch (e) { _progressPollingErrorCount++; @@ -554,11 +597,93 @@ class LocalLibraryNotifier extends Notifier { }); } + Future _handleLibraryScanProgress(Map progress) async { + final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; + final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( + 0.0, + 100.0, + ); + final currentFile = progress['current_file'] as String?; + final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; + final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; + final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; + final isComplete = progress['is_complete'] == true; + + final shouldUpdateState = + state.scanProgress != normalizedProgress || + state.scanCurrentFile != currentFile || + state.scanTotalFiles != totalFiles || + state.scannedFiles != scannedFiles || + state.scanErrorCount != errorCount; + + if (shouldUpdateState) { + state = state.copyWith( + scanProgress: normalizedProgress, + scanCurrentFile: currentFile, + scanTotalFiles: totalFiles, + scannedFiles: scannedFiles, + scanErrorCount: errorCount, + ); + } + + if (_shouldShowScanProgressNotification( + progress: normalizedProgress, + totalFiles: totalFiles, + isComplete: isComplete, + )) { + await _showScanProgressNotification( + progress: normalizedProgress, + scannedFiles: scannedFiles, + totalFiles: totalFiles, + currentFile: currentFile, + ); + } + + if (isComplete) { + _stopProgressPolling(); + } + } + void _stopProgressPolling() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; _progressPollingErrorCount = 0; _isProgressPollingInFlight = false; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + _resetScanNotificationTracking(); + } + + void _resetScanNotificationTracking() { + _lastScanNotificationPercent = -1; + _lastScanNotificationTotalFiles = -1; + _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); + } + + bool _shouldShowScanProgressNotification({ + required double progress, + required int totalFiles, + required bool isComplete, + }) { + final now = DateTime.now(); + final percent = progress.round().clamp(0, 100); + final percentChanged = percent != _lastScanNotificationPercent; + final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles; + final heartbeatDue = + now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat; + + if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) { + return false; + } + + _lastScanNotificationPercent = percent; + _lastScanNotificationTotalFiles = totalFiles; + _lastScanNotificationAt = now; + return true; } Future cancelScan() async { diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart new file mode 100644 index 00000000..c52bc6e2 --- /dev/null +++ b/lib/providers/playback_provider.dart @@ -0,0 +1,3880 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:audio_service/audio_service.dart' as audio_service; +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:spotiflac_android/models/playback_item.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final _log = AppLogger('PlaybackProvider'); + +// ─── Repeat mode ───────────────────────────────────────────────────────────── +enum RepeatMode { off, all, one } + +// ─── Lyrics types ──────────────────────────────────────────────────────────── + +/// A single word/syllable within a lyrics line, with its own timing. +class LyricsWord { + final String text; + final int startMs; + final int endMs; + + const LyricsWord({ + required this.text, + required this.startMs, + required this.endMs, + }); +} + +/// A single lyrics line, optionally with per-word timing. +class LyricsLine { + final int startMs; + final int endMs; + final String text; + final List words; + + const LyricsLine({ + required this.startMs, + required this.endMs, + required this.text, + this.words = const [], + }); + + bool get hasWordSync => words.isNotEmpty; +} + +/// Parsed lyrics data ready for display. +class LyricsData { + final List lines; + final String syncType; // LINE_SYNCED, UNSYNCED + final String source; // LRCLIB, Apple Music, etc. + final bool instrumental; + final bool isWordSynced; // true if any line has word-level timing + + const LyricsData({ + this.lines = const [], + this.syncType = '', + this.source = '', + this.instrumental = false, + this.isWordSynced = false, + }); + + bool get isSynced => syncType == 'LINE_SYNCED'; + bool get isEmpty => lines.isEmpty && !instrumental; +} + +// ─── State ─────────────────────────────────────────────────────────────────── +class PlaybackState { + final PlaybackItem? currentItem; + final bool isPlaying; + final bool isBuffering; + final bool isLoading; + final Duration position; + final Duration bufferedPosition; + final Duration duration; + final String? error; + final String? errorType; + final bool seekSupported; + + // Queue + final List queue; + final int currentIndex; + final bool shuffle; + final RepeatMode repeatMode; + + // Lyrics + final LyricsData? lyrics; + final bool lyricsLoading; + + const PlaybackState({ + this.currentItem, + this.isPlaying = false, + this.isBuffering = false, + this.isLoading = false, + this.position = Duration.zero, + this.bufferedPosition = Duration.zero, + this.duration = Duration.zero, + this.error, + this.errorType, + this.seekSupported = true, + this.queue = const [], + this.currentIndex = -1, + this.shuffle = false, + this.repeatMode = RepeatMode.off, + this.lyrics, + this.lyricsLoading = false, + }); + + bool get hasNext => queue.isNotEmpty && currentIndex < queue.length - 1; + bool get hasPrevious => queue.isNotEmpty && currentIndex > 0; + + PlaybackState copyWith({ + PlaybackItem? currentItem, + bool clearCurrentItem = false, + bool? isPlaying, + bool? isBuffering, + bool? isLoading, + Duration? position, + Duration? bufferedPosition, + Duration? duration, + String? error, + String? errorType, + bool? seekSupported, + bool clearError = false, + List? queue, + int? currentIndex, + bool? shuffle, + RepeatMode? repeatMode, + LyricsData? lyrics, + bool clearLyrics = false, + bool? lyricsLoading, + }) { + return PlaybackState( + currentItem: clearCurrentItem ? null : (currentItem ?? this.currentItem), + isPlaying: isPlaying ?? this.isPlaying, + isBuffering: isBuffering ?? this.isBuffering, + isLoading: isLoading ?? this.isLoading, + position: position ?? this.position, + bufferedPosition: bufferedPosition ?? this.bufferedPosition, + duration: duration ?? this.duration, + error: clearError ? null : (error ?? this.error), + errorType: clearError ? null : (errorType ?? this.errorType), + seekSupported: seekSupported ?? this.seekSupported, + queue: queue ?? this.queue, + currentIndex: currentIndex ?? this.currentIndex, + shuffle: shuffle ?? this.shuffle, + repeatMode: repeatMode ?? this.repeatMode, + lyrics: clearLyrics ? null : (lyrics ?? this.lyrics), + lyricsLoading: lyricsLoading ?? this.lyricsLoading, + ); + } +} + +// ─── Audio Handler (audio_service bridge) ──────────────────────────────────── +class _SpotiFLACAudioHandler extends audio_service.BaseAudioHandler + with audio_service.SeekHandler { + final Future Function() _onPlay; + final Future Function() _onPause; + final Future Function() _onSkipNext; + final Future Function() _onSkipPrevious; + final Future Function() _onStop; + final Future Function(Duration position) _onSeek; + final Future Function() _onToggleLove; + + _SpotiFLACAudioHandler({ + required Future Function() onPlay, + required Future Function() onPause, + required Future Function() onSkipNext, + required Future Function() onSkipPrevious, + required Future Function() onStop, + required Future Function(Duration position) onSeek, + required Future Function() onToggleLove, + }) : _onPlay = onPlay, + _onPause = onPause, + _onSkipNext = onSkipNext, + _onSkipPrevious = onSkipPrevious, + _onStop = onStop, + _onSeek = onSeek, + _onToggleLove = onToggleLove; + + @override + Future customAction(String name, [Map? extras]) async { + if (name == 'toggle_love') { + try { + await _onToggleLove(); + } catch (e) { + _log.e('Notification toggle love failed: $e'); + } + } + return super.customAction(name, extras); + } + + @override + Future play() async { + try { + await _onPlay(); + } catch (e) { + _log.e('Notification play failed: $e'); + } + } + + @override + Future pause() async { + try { + await _onPause(); + } catch (e) { + _log.e('Notification pause failed: $e'); + } + } + + @override + Future seek(Duration position) => _onSeek(position); + + @override + Future stop() async { + try { + await _onStop(); + } catch (e) { + _log.e('Notification stop failed: $e'); + } + } + + @override + Future skipToNext() async { + try { + await _onSkipNext(); + } catch (e) { + _log.e('Notification next failed: $e'); + } + } + + @override + Future skipToPrevious() async { + try { + await _onSkipPrevious(); + } catch (e) { + _log.e('Notification previous failed: $e'); + } + } +} + +// ─── Controller ────────────────────────────────────────────────────────────── +class PlaybackController extends Notifier { + static const String _playbackSnapshotKey = 'playback_snapshot_v1'; + static const String _smartQueueModelKey = 'smart_queue_model_v1'; + final AudioPlayer _player = AudioPlayer(); + final List> _subscriptions = []; + Timer? _snapshotSaveTimer; + Timer? _smartQueueModelSaveTimer; + _SpotiFLACAudioHandler? _audioHandler; + var _initialized = false; + static const Duration _prefetchThresholdFloor = Duration(seconds: 12); + static const Duration _prefetchThresholdCeiling = Duration(seconds: 40); + static const Duration _prefetchEarlyKickoffPosition = Duration(seconds: 6); + static const Duration _prefetchRetryCooldown = Duration(seconds: 3); + static const int _maxPrefetchAttemptsPerTrack = 2; + static const int _smartQueueTriggerRemainingTracks = 2; + static const int _smartQueueTargetRemainingTracks = 6; + static const int _smartQueueMaxAutoAddsPerSession = 40; + static const int _smartQueueRecentPlayedWindow = 40; + static const int _smartQueueCandidatePoolLimit = 28; + static const int _smartQueueRelatedArtistsLimit = 3; + static const int _smartQueueMaxAffinityKeys = 160; + static const int _smartQueueSessionWindowSize = 10; + static const int _smartQueueMaxArtistRepeats = 2; + static const int _smartQueueMaxDecadeDriftYears = 20; + static const int _smartQueueMaxTempoJumpBpm = 42; + static const int _smartQueueMaxTempoHints = 720; + static const int _smartQueueMaxSkipStreak = 6; + static const double _smartQueuePrimarySourceRatio = 0.68; + static const String _smartQueueSpotifyExtensionId = 'spotify-web'; + static const Duration _smartQueueRefillCooldown = Duration(seconds: 18); + static const Duration _smartQueueSearchCacheTtl = Duration(minutes: 3); + static const Duration _smartQueueFeedbackMaxAge = Duration(hours: 6); + static const double _smartQueueLearningRate = 0.2; + int? _prefetchingQueueIndex; + int? _lastPrefetchAttemptIndex; + final Map _prefetchAttemptCounts = {}; + final Map _prefetchLastAttemptAt = {}; + final Map> _prefetchLatencyByServiceMs = + >{}; + final Random _smartQueueRandom = Random(); + final List _recentPlayedTrackKeys = []; + final Map + _smartQueuePendingFeedbackByTrack = {}; + final Map _smartQueueSearchCache = + {}; + final Map + _smartQueueRelatedArtistsCache = {}; + final Map _smartQueueWeights = { + 'bias': -0.15, + 'same_artist': 0.06, + 'same_album': 0.04, + 'duration_similarity': 0.8, + 'source_match': 0.18, + 'release_year_similarity': 0.32, + 'artist_affinity': 0.55, + 'source_affinity': 0.3, + 'novelty': 0.65, + 'session_alignment': 0.42, + 'hour_affinity': 0.21, + 'skip_context': 0.29, + 'tempo_continuity': 0.26, + 'year_cohesion': 0.22, + }; + final Map _smartQueueArtistAffinity = {}; + final Map _smartQueueSourceAffinity = {}; + final Map _smartQueueHourAffinity = {}; + final Map _smartQueueTempoHintByTrackKey = {}; + final List<_SmartQueueSessionSignal> _smartQueueSessionSignals = + <_SmartQueueSessionSignal>[]; + bool _smartQueueRefillInFlight = false; + DateTime? _lastSmartQueueRefillAt; + int _smartQueueAutoAddedCount = 0; + int _smartQueueSkipStreak = 0; + _SmartQueueSessionProfile _smartQueueSessionProfile = + const _SmartQueueSessionProfile( + mode: _SmartQueueSessionMode.balanced, + targetDurationSec: 215, + preferredSourceKey: '', + ); + + // Shuffle order: indices into queue + List _shuffleOrder = []; + int _shufflePosition = -1; + int _playRequestEpoch = 0; + Duration? _pendingResumePosition; + int? _pendingResumeIndex; + int _lastProgressSnapshotMs = -1; + int _lyricsGeneration = 0; + AppLifecycleListener? _appLifecycleListener; + + @override + PlaybackState build() { + if (!_initialized) { + _initialized = true; + _init(); + ref.onDispose(_disposeInternal); + } + return const PlaybackState(); + } + + void _init() { + unawaited(_configureAudioSession()); + unawaited(_initAudioService()); + unawaited(_restorePlaybackSnapshot()); + unawaited(_restoreSmartQueueModel()); + _appLifecycleListener ??= AppLifecycleListener( + onInactive: () => unawaited(_savePlaybackSnapshot()), + onPause: () => unawaited(_savePlaybackSnapshot()), + onDetach: () => unawaited(_savePlaybackSnapshot()), + onHide: () => unawaited(_savePlaybackSnapshot()), + ); + + ref.listen(libraryCollectionsProvider, (previous, next) { + final track = state.currentItem?.track; + if (track != null) { + final wasLoved = previous?.isLoved(track) ?? false; + final isLoved = next.isLoved(track); + if (wasLoved != isLoved) { + _syncServicePlaybackState(_player.processingState, _player.playing); + } + } + }); + + _subscriptions.add( + _player.playerStateStream.listen((playerState) { + final playing = playerState.playing; + final processingState = playerState.processingState; + + state = state.copyWith( + isPlaying: playing, + isBuffering: + processingState == ProcessingState.loading || + processingState == ProcessingState.buffering, + isLoading: false, + ); + + // Update audio_service playback state + _syncServicePlaybackState(processingState, playing); + + // Handle track completion + if (processingState == ProcessingState.completed) { + _onTrackCompleted(); + } + }), + ); + + _subscriptions.add( + _player + .createPositionStream( + minPeriod: const Duration(milliseconds: 16), + maxPeriod: const Duration(milliseconds: 33), + ) + .listen((position) { + final hasPendingResume = + state.currentIndex >= 0 && + _pendingResumePositionForIndex(state.currentIndex) != null; + final shouldKeepRestoredPosition = + _player.processingState == ProcessingState.idle && + hasPendingResume && + position == Duration.zero && + state.position > Duration.zero; + if (shouldKeepRestoredPosition) { + return; + } + state = state.copyWith(position: position); + _maybePrefetchNext(position); + _maybeTriggerSmartQueueRefill(position); + _scheduleSnapshotSaveForProgress(position); + }), + ); + + _subscriptions.add( + _player.bufferedPositionStream.listen((bufferedPosition) { + state = state.copyWith(bufferedPosition: bufferedPosition); + }), + ); + + _subscriptions.add( + _player.durationStream.listen((duration) { + final hasPendingResume = + state.currentIndex >= 0 && + _pendingResumePositionForIndex(state.currentIndex) != null; + final shouldKeepRestoredDuration = + _player.processingState == ProcessingState.idle && + hasPendingResume && + duration == null && + state.duration > Duration.zero; + if (shouldKeepRestoredDuration) { + return; + } + final fallbackDuration = _fallbackDurationForItem(state.currentItem); + final resolvedDuration = duration != null && duration > Duration.zero + ? duration + : fallbackDuration; + if (state.duration != resolvedDuration) { + state = state.copyWith(duration: resolvedDuration); + } + + if (duration != null && + duration > Duration.zero && + state.currentIndex >= 0 && + state.currentIndex < state.queue.length) { + final durationMs = duration.inMilliseconds; + final currentItem = state.currentItem; + final updatedCurrentItem = + currentItem != null && currentItem.durationMs != durationMs + ? PlaybackItem( + id: currentItem.id, + title: currentItem.title, + artist: currentItem.artist, + album: currentItem.album, + coverUrl: currentItem.coverUrl, + sourceUri: currentItem.sourceUri, + isLocal: currentItem.isLocal, + service: currentItem.service, + durationMs: durationMs, + format: currentItem.format, + bitDepth: currentItem.bitDepth, + sampleRate: currentItem.sampleRate, + bitrate: currentItem.bitrate, + track: currentItem.track, + ) + : currentItem; + + final queueItem = state.queue[state.currentIndex]; + final shouldUpdateQueueItem = queueItem.durationMs != durationMs; + + if (updatedCurrentItem != currentItem || shouldUpdateQueueItem) { + final updatedQueue = [...state.queue]; + if (shouldUpdateQueueItem) { + updatedQueue[state.currentIndex] = PlaybackItem( + id: queueItem.id, + title: queueItem.title, + artist: queueItem.artist, + album: queueItem.album, + coverUrl: queueItem.coverUrl, + sourceUri: queueItem.sourceUri, + isLocal: queueItem.isLocal, + service: queueItem.service, + durationMs: durationMs, + format: queueItem.format, + bitDepth: queueItem.bitDepth, + sampleRate: queueItem.sampleRate, + bitrate: queueItem.bitrate, + track: queueItem.track, + ); + } + + state = state.copyWith( + currentItem: updatedCurrentItem, + queue: updatedQueue, + ); + unawaited(_savePlaybackSnapshot()); + } + } + + // Update notification duration when known + if (state.currentItem != null && duration != null) { + _updateMediaItemNotification(state.currentItem!); + } + }), + ); + + _subscriptions.add( + _player.playbackEventStream.listen( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + _log.e('Playback error: $error'); + state = state.copyWith( + isLoading: false, + isPlaying: false, + isBuffering: false, + error: error.toString(), + errorType: 'playback_failed', + ); + }, + ), + ); + } + + Future _initAudioService() async { + try { + _audioHandler = + await audio_service.AudioService.init<_SpotiFLACAudioHandler>( + builder: () => _SpotiFLACAudioHandler( + onPlay: _handleNotificationPlay, + onPause: _handleNotificationPause, + onSkipNext: _handleNotificationNext, + onSkipPrevious: _handleNotificationPrevious, + onStop: _handleNotificationStop, + onSeek: seek, + onToggleLove: _handleNotificationToggleLove, + ), + config: const audio_service.AudioServiceConfig( + androidNotificationChannelId: 'com.zarz.spotiflac.playback', + androidNotificationChannelName: 'Music Playback', + androidNotificationOngoing: true, + androidShowNotificationBadge: true, + androidStopForegroundOnPause: true, + ), + ); + } catch (e) { + _log.w('AudioService init failed: $e'); + } + } + + Future _configureAudioSession() async { + try { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + } catch (e) { + _log.w('Audio session configuration failed: $e'); + } + } + + Future _handleNotificationPlay() async { + if (_player.processingState == ProcessingState.idle && + state.queue.isNotEmpty) { + final resumeIndex = state.currentIndex < 0 ? 0 : state.currentIndex; + await _playQueueIndex(resumeIndex); + return; + } + await _player.play(); + } + + Future _handleNotificationPause() async { + await _player.pause(); + } + + Future _handleNotificationNext() async { + await skipNext(); + } + + Future _handleNotificationPrevious() async { + await skipPrevious(); + } + + Future _handleNotificationStop() async { + await stop(); + } + + Future _handleNotificationToggleLove() async { + final track = state.currentItem?.track; + if (track != null) { + await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); + } + } + + void _syncServicePlaybackState( + ProcessingState processingState, + bool playing, + ) { + final handler = _audioHandler; + if (handler == null) return; + + audio_service.AudioProcessingState serviceState; + switch (processingState) { + case ProcessingState.idle: + serviceState = audio_service.AudioProcessingState.idle; + case ProcessingState.loading: + serviceState = audio_service.AudioProcessingState.loading; + case ProcessingState.buffering: + serviceState = audio_service.AudioProcessingState.buffering; + case ProcessingState.ready: + serviceState = audio_service.AudioProcessingState.ready; + case ProcessingState.completed: + serviceState = audio_service.AudioProcessingState.completed; + } + + final track = state.currentItem?.track; + final isLoved = + track != null && ref.read(libraryCollectionsProvider).isLoved(track); + + final controls = [ + audio_service.MediaControl.custom( + androidIcon: isLoved + ? 'drawable/ic_stat_favorite' + : 'drawable/ic_stat_favorite_border', + label: isLoved ? 'Unlove' : 'Love', + name: 'toggle_love', + ), + audio_service.MediaControl.skipToPrevious, + if (playing) + audio_service.MediaControl.pause + else + audio_service.MediaControl.play, + audio_service.MediaControl.skipToNext, + ]; + + final systemActions = {}; + if (state.seekSupported) { + systemActions.addAll(const { + audio_service.MediaAction.seek, + audio_service.MediaAction.seekForward, + audio_service.MediaAction.seekBackward, + }); + } + + handler.playbackState.add( + audio_service.PlaybackState( + controls: controls, + systemActions: systemActions, + androidCompactActionIndices: _compactIndices(controls), + processingState: serviceState, + playing: playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + ), + ); + } + + List _compactIndices(List controls) { + // Always show prev(0), play/pause(1), next(2) in compact notification + final count = controls.length; + if (count >= 3) return const [0, 1, 2]; + return List.generate(count, (i) => i); + } + + Uri? _resolveMediaArtUri(String coverUrl) { + final raw = coverUrl.trim(); + if (raw.isEmpty) return null; + + if (raw.startsWith('http://') || + raw.startsWith('https://') || + raw.startsWith('file://') || + raw.startsWith('content://')) { + return Uri.tryParse(raw); + } + + // Treat bare local paths as file URIs so notification can load local art. + return Uri.file(raw); + } + + void _updateMediaItemNotification(PlaybackItem item) { + final handler = _audioHandler; + if (handler == null) return; + + handler.mediaItem.add( + audio_service.MediaItem( + id: item.id, + album: item.album, + title: item.title, + artist: item.artist, + duration: state.duration, + artUri: _resolveMediaArtUri(item.coverUrl), + extras: { + if ((item.track?.isrc ?? '').trim().isNotEmpty) + 'isrc': item.track!.isrc!.trim(), + 'trackName': item.title, + 'artistName': item.artist, + if (item.album.isNotEmpty) 'albumName': item.album, + if (item.coverUrl.isNotEmpty) 'coverUrl': item.coverUrl, + if (item.sourceUri.isNotEmpty) 'sourceUri': item.sourceUri, + 'isLocal': item.isLocal, + if (item.service.isNotEmpty) 'service': item.service, + if (item.format.isNotEmpty) 'format': item.format, + }, + ), + ); + } + + // ─── Track completion ──────────────────────────────────────────────────── + void _onTrackCompleted() { + _learnFromCurrentTrackOutcome(completedNaturally: true); + final completedItem = state.currentItem; + if (completedItem != null) { + _rememberRecentPlayed(completedItem); + } + + if (state.repeatMode == RepeatMode.one) { + // Replay current track + unawaited(_restartCurrentTrack(playAfterSeek: true)); + return; + } + + final nextIndex = _resolveNextIndex(); + if (nextIndex != null) { + unawaited(_playQueueIndex(nextIndex)); + } else { + unawaited(_handleQueueExhausted()); + } + } + + Future _handleQueueExhausted() async { + final added = await _autoRefillSmartQueue(force: true); + if (added > 0) { + final nextIndex = _resolveNextIndex(); + if (nextIndex != null) { + await _playQueueIndex(nextIndex); + return; + } + } + + // Queue exhausted + state = state.copyWith(isPlaying: false, position: Duration.zero); + _syncServicePlaybackState(ProcessingState.completed, false); + } + + Future _restartCurrentTrack({bool playAfterSeek = false}) async { + try { + if (state.seekSupported) { + await _player.seek(Duration.zero); + if (playAfterSeek) { + await _player.play(); + } + return; + } + + final index = state.currentIndex; + if (index >= 0 && index < state.queue.length) { + await _playQueueIndex(index); + return; + } + + _setPlaybackError( + 'Failed to restart track from the beginning.', + type: 'playback_failed', + ); + } catch (e) { + _log.e('Failed to restart current track: $e'); + _setPlaybackError('Failed to restart track: $e', type: 'playback_failed'); + } + } + + int? _resolveNextIndex() { + if (state.queue.isEmpty) return null; + + if (state.shuffle) { + _shufflePosition++; + if (_shufflePosition < _shuffleOrder.length) { + return _shuffleOrder[_shufflePosition]; + } + // Shuffle exhausted + if (state.repeatMode == RepeatMode.all) { + _regenerateShuffleOrder(); + _shufflePosition = 0; + return _shuffleOrder.isNotEmpty ? _shuffleOrder[0] : null; + } + return null; + } + + final next = state.currentIndex + 1; + if (next < state.queue.length) return next; + if (state.repeatMode == RepeatMode.all) return 0; + return null; + } + + int? _resolvePreviousIndex() { + if (state.queue.isEmpty) return null; + + if (state.shuffle) { + if (_shufflePosition > 0) { + _shufflePosition--; + return _shuffleOrder[_shufflePosition]; + } + return null; + } + + final prev = state.currentIndex - 1; + if (prev >= 0) return prev; + if (state.repeatMode == RepeatMode.all) return state.queue.length - 1; + return null; + } + + void _regenerateShuffleOrder() { + final rng = Random(); + _shuffleOrder = List.generate(state.queue.length, (i) => i)..shuffle(rng); + } + + void _regenerateShuffleOrderPreservingCurrentProgress() { + final queueLength = state.queue.length; + if (queueLength == 0) { + _shuffleOrder = []; + _shufflePosition = -1; + return; + } + + final currentIndex = state.currentIndex; + if (currentIndex < 0 || currentIndex >= queueLength) { + _regenerateShuffleOrder(); + _shufflePosition = -1; + return; + } + + final rng = Random(); + final playedAndCurrent = List.generate(currentIndex + 1, (i) => i); + final upcoming = List.generate( + queueLength - currentIndex - 1, + (i) => currentIndex + i + 1, + )..shuffle(rng); + + _shuffleOrder = [...playedAndCurrent, ...upcoming]; + _shufflePosition = currentIndex; + } + + List getQueueDisplayOrder() { + if (state.queue.isEmpty) return const []; + + if (!state.shuffle) { + return List.generate(state.queue.length, (i) => i); + } + + final seen = {}; + final normalized = []; + for (final idx in _shuffleOrder) { + if (idx >= 0 && idx < state.queue.length && seen.add(idx)) { + normalized.add(idx); + } + } + for (var i = 0; i < state.queue.length; i++) { + if (seen.add(i)) { + normalized.add(i); + } + } + return normalized; + } + + int getCurrentDisplayQueuePosition({List? displayOrder}) { + final order = displayOrder ?? getQueueDisplayOrder(); + if (order.isEmpty) return -1; + + if (!state.shuffle) { + if (state.currentIndex < 0 || state.currentIndex >= order.length) { + return 0; + } + return state.currentIndex; + } + + final position = order.indexOf(state.currentIndex); + if (position >= 0) return position; + return 0; + } + + int _startNewPlayRequest() { + _playRequestEpoch++; + return _playRequestEpoch; + } + + void _resetPrefetchCycleState() { + _prefetchingQueueIndex = null; + _lastPrefetchAttemptIndex = null; + _prefetchAttemptCounts.clear(); + _prefetchLastAttemptAt.clear(); + } + + bool _isPlayRequestCurrent(int epoch) => epoch == _playRequestEpoch; + + void _clearLyricsForTrackChange({PlaybackItem? upcomingItem}) { + // Invalidate any in-flight lyrics fetch from previous track. + _lyricsGeneration++; + state = state.copyWith( + currentItem: upcomingItem ?? state.currentItem, + lyricsLoading: false, + clearLyrics: true, + ); + } + + Map _serializePlaybackItem(PlaybackItem item) => { + 'id': item.id, + 'title': item.title, + 'artist': item.artist, + 'album': item.album, + 'coverUrl': item.coverUrl, + 'sourceUri': item.sourceUri, + 'isLocal': item.isLocal, + 'service': item.service, + 'durationMs': item.durationMs, + 'format': item.format, + 'bitDepth': item.bitDepth, + 'sampleRate': item.sampleRate, + 'bitrate': item.bitrate, + if (item.track != null) 'track': item.track!.toJson(), + }; + + PlaybackItem? _deserializePlaybackItem(Map? json) { + if (json == null) return null; + final id = (json['id'] as String?)?.trim() ?? ''; + if (id.isEmpty) return null; + + Track? track; + try { + final trackJson = json['track']; + if (trackJson is Map) { + track = Track.fromJson(Map.from(trackJson)); + } + } catch (_) {} + + return PlaybackItem( + id: id, + title: (json['title'] as String?) ?? '', + artist: (json['artist'] as String?) ?? '', + album: (json['album'] as String?) ?? '', + coverUrl: (json['coverUrl'] as String?) ?? '', + sourceUri: (json['sourceUri'] as String?) ?? '', + isLocal: json['isLocal'] == true, + service: (json['service'] as String?) ?? '', + durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, + format: (json['format'] as String?) ?? '', + bitDepth: (json['bitDepth'] as num?)?.toInt() ?? 0, + sampleRate: (json['sampleRate'] as num?)?.toInt() ?? 0, + bitrate: (json['bitrate'] as num?)?.toInt() ?? 0, + track: track, + ); + } + + Future _savePlaybackSnapshot() async { + try { + final prefs = await SharedPreferences.getInstance(); + final payload = { + 'queue': state.queue + .map(_serializePlaybackItem) + .toList(growable: false), + 'currentIndex': state.currentIndex, + 'positionMs': state.position.inMilliseconds, + 'durationMs': state.duration > Duration.zero + ? state.duration.inMilliseconds + : (state.currentItem?.durationMs ?? 0), + 'shuffle': state.shuffle, + 'repeatMode': state.repeatMode.index, + }; + await prefs.setString(_playbackSnapshotKey, jsonEncode(payload)); + } catch (e) { + _log.w('Failed to save playback snapshot: $e'); + } + } + + Future _restorePlaybackSnapshot() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_playbackSnapshotKey); + if (raw == null || raw.isEmpty) return; + + final decoded = jsonDecode(raw); + if (decoded is! Map) return; + final payload = Map.from(decoded); + + final queueRaw = payload['queue']; + final restoredQueue = []; + if (queueRaw is List) { + for (final entry in queueRaw) { + if (entry is! Map) continue; + final item = _deserializePlaybackItem( + Map.from(entry), + ); + if (item != null) restoredQueue.add(item); + } + } + if (restoredQueue.isEmpty) return; + + var restoredIndex = (payload['currentIndex'] as num?)?.toInt() ?? 0; + restoredIndex = restoredIndex.clamp(0, restoredQueue.length - 1).toInt(); + final restoredPositionMs = (payload['positionMs'] as num?)?.toInt() ?? 0; + final restoredDurationMs = (payload['durationMs'] as num?)?.toInt() ?? 0; + final restoredRepeatIndex = (payload['repeatMode'] as num?)?.toInt() ?? 0; + final restoredRepeatMode = + restoredRepeatIndex >= 0 && + restoredRepeatIndex < RepeatMode.values.length + ? RepeatMode.values[restoredRepeatIndex] + : RepeatMode.off; + + state = state.copyWith( + queue: restoredQueue, + currentIndex: restoredIndex, + currentItem: restoredQueue[restoredIndex], + isPlaying: false, + isBuffering: false, + isLoading: false, + position: Duration(milliseconds: restoredPositionMs), + bufferedPosition: Duration.zero, + duration: restoredDurationMs > 0 + ? Duration(milliseconds: restoredDurationMs) + : (restoredQueue[restoredIndex].durationMs > 0 + ? Duration( + milliseconds: restoredQueue[restoredIndex].durationMs, + ) + : Duration.zero), + shuffle: payload['shuffle'] == true, + repeatMode: restoredRepeatMode, + clearError: true, + ); + _pendingResumePosition = restoredPositionMs > 0 + ? Duration(milliseconds: restoredPositionMs) + : null; + _pendingResumeIndex = restoredPositionMs > 0 ? restoredIndex : null; + _lastProgressSnapshotMs = restoredPositionMs; + + if (state.shuffle) { + _regenerateShuffleOrder(); + _shufflePosition = _shuffleOrder.indexOf(state.currentIndex); + if (_shufflePosition < 0) _shufflePosition = 0; + } else { + _shuffleOrder = []; + _shufflePosition = -1; + } + } catch (e) { + _log.w('Failed to restore playback snapshot: $e'); + } + } + + Future _restoreSmartQueueModel() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_smartQueueModelKey); + if (raw == null || raw.isEmpty) return; + + final decoded = jsonDecode(raw); + if (decoded is! Map) return; + final payload = Map.from(decoded); + + final weightsRaw = payload['weights']; + if (weightsRaw is Map) { + for (final entry in weightsRaw.entries) { + final key = entry.key.toString(); + final value = (entry.value as num?)?.toDouble(); + if (value == null) continue; + _smartQueueWeights[key] = value; + } + } + + _smartQueueArtistAffinity.clear(); + final artistRaw = payload['artistAffinity']; + if (artistRaw is Map) { + for (final entry in artistRaw.entries) { + final key = entry.key.toString().trim().toLowerCase(); + if (key.isEmpty) continue; + final value = (entry.value as num?)?.toDouble(); + if (value == null) continue; + _smartQueueArtistAffinity[key] = value.clamp(-1.0, 1.0); + } + } + + _smartQueueSourceAffinity.clear(); + final sourceRaw = payload['sourceAffinity']; + if (sourceRaw is Map) { + for (final entry in sourceRaw.entries) { + final key = entry.key.toString().trim().toLowerCase(); + if (key.isEmpty) continue; + final value = (entry.value as num?)?.toDouble(); + if (value == null) continue; + _smartQueueSourceAffinity[key] = value.clamp(-1.0, 1.0); + } + } + + _smartQueueHourAffinity.clear(); + final hourRaw = payload['hourAffinity']; + if (hourRaw is Map) { + for (final entry in hourRaw.entries) { + final key = entry.key.toString().trim().toLowerCase(); + if (key.isEmpty) continue; + final value = (entry.value as num?)?.toDouble(); + if (value == null) continue; + _smartQueueHourAffinity[key] = value.clamp(-1.0, 1.0); + } + } + } catch (e) { + _log.w('Failed to restore smart queue model: $e'); + } + } + + void _scheduleSmartQueueModelSave() { + _smartQueueModelSaveTimer?.cancel(); + _smartQueueModelSaveTimer = Timer(const Duration(seconds: 2), () { + unawaited(_persistSmartQueueModel()); + }); + } + + Future _persistSmartQueueModel() async { + try { + final prefs = await SharedPreferences.getInstance(); + final payload = { + 'weights': _smartQueueWeights, + 'artistAffinity': _smartQueueArtistAffinity, + 'sourceAffinity': _smartQueueSourceAffinity, + 'hourAffinity': _smartQueueHourAffinity, + }; + await prefs.setString(_smartQueueModelKey, jsonEncode(payload)); + } catch (e) { + _log.w('Failed to save smart queue model: $e'); + } + } + + PlaybackItem _buildQueueItemFromTrack(Track track) { + final localState = ref.read(localLibraryProvider); + final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; + + LocalLibraryItem? localItem; + if (isLocalSource) { + for (final item in localState.items) { + if (item.id == track.id) { + localItem = item; + break; + } + } + } + + if (localItem == null) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + localItem = localState.getByIsrc(isrc); + } + } + + localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + + if (localItem != null && localItem.filePath.isNotEmpty) { + final localUri = _uriFromPath(localItem.filePath); + final localDurationMs = + localItem.duration != null && localItem.duration! > 0 + ? localItem.duration! * 1000 + : _trackDurationMs(track); + return PlaybackItem( + id: localItem.id, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + sourceUri: localUri.toString(), + isLocal: true, + service: 'offline', + durationMs: localDurationMs, + track: track, + ); + } + + return PlaybackItem( + id: track.id, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + sourceUri: '', + durationMs: _trackDurationMs(track), + track: track, + ); + } + + int _trackDurationMs(Track track) { + if (track.duration <= 0) return 0; + return track.duration * 1000; + } + + Duration _fallbackDurationForItem(PlaybackItem? item) { + final ms = item?.durationMs ?? 0; + if (ms <= 0) return Duration.zero; + return Duration(milliseconds: ms); + } + + // ─── Public: play local file ───────────────────────────────────────────── + Future playLocalPath({ + required String path, + required String title, + required String artist, + String album = '', + String coverUrl = '', + }) async { + final requestEpoch = _startNewPlayRequest(); + _resetPrefetchCycleState(); + _resetSmartQueueSessionState(clearRecent: true); + _pendingResumePosition = null; + _pendingResumeIndex = null; + final uri = _uriFromPath(path); + final item = PlaybackItem( + id: path, + title: title, + artist: artist, + album: album, + coverUrl: coverUrl, + sourceUri: uri.toString(), + isLocal: true, + service: 'offline', + ); + + _clearLyricsForTrackChange(upcomingItem: item); + + // Replacing single-track playback should also replace queue to avoid stale UI. + state = state.copyWith( + seekSupported: true, + clearError: true, + queue: [item], + currentIndex: 0, + ); + unawaited(_savePlaybackSnapshot()); + + if (state.shuffle) { + _regenerateShuffleOrder(); + _shufflePosition = _shuffleOrder.indexOf(0); + if (_shufflePosition < 0) _shufflePosition = 0; + } else { + _shuffleOrder = []; + _shufflePosition = -1; + } + + await _setSourceAndPlay(uri, item, expectedRequestEpoch: requestEpoch); + } + + // ─── Public: play a list of tracks (set queue) ─────────────────────────── + Future playTrackList(List tracks, {int startIndex = 0}) async { + if (tracks.isEmpty) return; + _resetPrefetchCycleState(); + _resetSmartQueueSessionState(clearRecent: true); + + final items = tracks.map(_buildQueueItemFromTrack).toList(growable: false); + _pendingResumePosition = null; + _pendingResumeIndex = null; + + state = state.copyWith( + queue: items, + currentIndex: startIndex.clamp(0, items.length - 1), + ); + unawaited(_savePlaybackSnapshot()); + + if (state.shuffle) { + _regenerateShuffleOrder(); + // Place the starting track at the front of the shuffle order + // so playback begins from it, then continues in random order. + final pos = _shuffleOrder.indexOf(state.currentIndex); + if (pos > 0) { + _shuffleOrder.removeAt(pos); + _shuffleOrder.insert(0, state.currentIndex); + } + _shufflePosition = 0; + } + + await _playQueueIndex(state.currentIndex); + } + + // ─── Public: add track to queue ────────────────────────────────────────── + void addToQueue(Track track) { + final item = _buildQueueItemFromTrack(track); + + final newQueue = [...state.queue, item]; + state = state.copyWith(queue: newQueue); + unawaited(_savePlaybackSnapshot()); + + if (state.shuffle) { + _shuffleOrder.add(newQueue.length - 1); + } + } + + // ─── Public: remove from queue ─────────────────────────────────────────── + void removeFromQueue(int index) { + if (index < 0 || index >= state.queue.length) return; + + final newQueue = [...state.queue]..removeAt(index); + var newIndex = state.currentIndex; + if (index < newIndex) { + newIndex--; + } else if (index == newIndex) { + newIndex = newIndex.clamp(0, newQueue.length - 1); + } + + state = state.copyWith(queue: newQueue, currentIndex: newIndex); + unawaited(_savePlaybackSnapshot()); + if (state.shuffle) _regenerateShuffleOrder(); + } + + // ─── Public: clear queue ───────────────────────────────────────────────── + void clearQueue() { + _resetPrefetchCycleState(); + _resetSmartQueueSessionState(clearRecent: false); + _lastProgressSnapshotMs = -1; + state = state.copyWith(queue: [], currentIndex: -1); + unawaited(_savePlaybackSnapshot()); + _shuffleOrder = []; + _shufflePosition = -1; + _pendingResumePosition = null; + _pendingResumeIndex = null; + } + + // ─── Public: jump to specific queue index ──────────────────────────────── + Future playQueueIndex(int index) async { + if (index < 0 || index >= state.queue.length) return; + if (index == state.currentIndex) return; + await _playQueueIndex(index); + } + + // ─── Public: skip next / previous ──────────────────────────────────────── + Future skipNext() async { + _learnFromCurrentTrackOutcome(completedNaturally: false); + final nextIndex = _resolveNextIndex(); + if (nextIndex != null) { + await _playQueueIndex(nextIndex); + } + } + + Future skipPrevious() async { + // If > 3 seconds into track, restart instead of going previous + if (_player.position.inSeconds > 3) { + await _restartCurrentTrack(); + return; + } + + final prevIndex = _resolvePreviousIndex(); + if (prevIndex != null) { + await _playQueueIndex(prevIndex); + } else { + await _restartCurrentTrack(); + } + } + + // ─── Public: toggle shuffle ────────────────────────────────────────────── + void toggleShuffle() { + final newShuffle = !state.shuffle; + state = state.copyWith(shuffle: newShuffle); + + if (newShuffle) { + _regenerateShuffleOrderPreservingCurrentProgress(); + } else { + _shuffleOrder = []; + _shufflePosition = -1; + } + unawaited(_savePlaybackSnapshot()); + } + + // ─── Public: cycle repeat mode ─────────────────────────────────────────── + void cycleRepeatMode() { + final modes = RepeatMode.values; + final next = (state.repeatMode.index + 1) % modes.length; + state = state.copyWith(repeatMode: modes[next]); + } + + // ─── Public: toggle play/pause ─────────────────────────────────────────── + Future togglePlayPause() async { + if (_player.playing) { + await _player.pause(); + } else { + if (_player.processingState == ProcessingState.completed) { + final hasCurrentTrack = + state.currentIndex >= 0 || state.currentItem != null; + if (hasCurrentTrack) { + await _restartCurrentTrack(playAfterSeek: true); + return; + } + } + + if (_player.processingState == ProcessingState.idle && + state.queue.isNotEmpty) { + final resumeIndex = state.currentIndex < 0 ? 0 : state.currentIndex; + await _playQueueIndex(resumeIndex); + return; + } + await _player.play(); + } + } + + // ─── Public: seek ──────────────────────────────────────────────────────── + Future seek(Duration position) async { + if (!state.seekSupported) { + _setPlaybackError( + 'Seeking is not supported for this stream.', + type: 'seek_not_supported', + ); + return; + } + await _player.seek(position); + } + + // ─── Public: stop ──────────────────────────────────────────────────────── + Future stop() async { + _startNewPlayRequest(); + _lyricsGeneration++; + final lastKnownPosition = state.position; + final lastKnownDuration = state.duration; + await FFmpegService.stopLiveDecryptedStream(); + await FFmpegService.stopNativeDashManifestPlayback(); + await FFmpegService.cleanupInactivePreparedNativeDashManifests(); + await _player.stop(); + _resetPrefetchCycleState(); + _lastProgressSnapshotMs = lastKnownPosition.inMilliseconds; + _audioHandler?.playbackState.add( + audio_service.PlaybackState( + processingState: audio_service.AudioProcessingState.idle, + playing: false, + ), + ); + _audioHandler?.mediaItem.add(null); + + state = state.copyWith( + isPlaying: false, + isBuffering: false, + isLoading: false, + seekSupported: true, + position: lastKnownPosition, + bufferedPosition: Duration.zero, + duration: lastKnownDuration, + clearError: true, + clearLyrics: true, + ); + unawaited(_savePlaybackSnapshot()); + } + + /// Stops playback and dismisses the mini player UI entirely. + Future dismissPlayer() async { + await stop(); + _pendingResumePosition = null; + _pendingResumeIndex = null; + _lastProgressSnapshotMs = -1; + + state = state.copyWith( + clearCurrentItem: true, + queue: const [], + currentIndex: -1, + position: Duration.zero, + bufferedPosition: Duration.zero, + duration: Duration.zero, + clearError: true, + clearLyrics: true, + lyricsLoading: false, + ); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_playbackSnapshotKey); + } catch (e) { + _log.w('Failed to clear playback snapshot on dismiss: $e'); + } + } + + void clearError() { + state = state.copyWith(clearError: true); + } + + // ─── Internal ──────────────────────────────────────────────────────────── + + Future _playQueueIndex(int index) async { + if (index < 0 || index >= state.queue.length) return; + + final previousItem = state.currentItem; + final requestEpoch = _startNewPlayRequest(); + _resetPrefetchCycleState(); + final pendingResumePosition = _pendingResumePositionForIndex(index); + final item = state.queue[index]; + if (previousItem != null && + _trackKeyFromPlaybackItem(previousItem) != + _trackKeyFromPlaybackItem(item)) { + _rememberRecentPlayed(previousItem); + } + _clearLyricsForTrackChange(upcomingItem: item); + state = state.copyWith( + currentIndex: index, + currentItem: item, + isLoading: true, + isBuffering: true, + isPlaying: false, + seekSupported: _inferSeekSupportedForQueueItem(item), + position: + pendingResumePosition != null && pendingResumePosition > Duration.zero + ? pendingResumePosition + : Duration.zero, + bufferedPosition: Duration.zero, + duration: _fallbackDurationForItem(item), + clearError: true, + ); + await _savePlaybackSnapshot(); + + if (item.sourceUri.isEmpty) { + final skipped = await _handleQueueItemPlaybackFailure( + failedIndex: index, + expectedRequestEpoch: requestEpoch, + error: Exception('Track is not available locally. Download it first.'), + fallbackType: 'source_missing', + ); + if (skipped) { + return; + } + return; + } + + // Already have a URI + if (item.sourceUri.isNotEmpty) { + final uri = _uriFromPath(item.sourceUri); + try { + await _setSourceAndPlay( + uri, + item, + initialPosition: pendingResumePosition, + expectedRequestEpoch: requestEpoch, + ); + if (!_isPlayRequestCurrent(requestEpoch) || + state.currentIndex != index) { + return; + } + _clearPendingResumeForIndex(index); + } catch (e) { + if (!_isPlayRequestCurrent(requestEpoch)) return; + final skipped = await _handleQueueItemPlaybackFailure( + failedIndex: index, + expectedRequestEpoch: requestEpoch, + error: e, + fallbackType: 'playback_failed', + ); + if (skipped) { + return; + } + } + } + } + + Future _setSourceAndPlay( + Uri uri, + PlaybackItem item, { + Duration? initialPosition, + int? expectedRequestEpoch, + }) async { + if (expectedRequestEpoch != null && + !_isPlayRequestCurrent(expectedRequestEpoch)) { + return; + } + final sourceUrl = uri.toString(); + await FFmpegService.activatePreparedNativeDashManifest(sourceUrl); + if (!FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { + await FFmpegService.stopLiveDecryptedStream(); + } + if (!FFmpegService.isActiveNativeDashManifestUrl(sourceUrl)) { + await FFmpegService.stopNativeDashManifestPlayback(); + } + + final startPosition = + initialPosition != null && initialPosition > Duration.zero + ? initialPosition + : Duration.zero; + state = state.copyWith( + currentItem: item, + isLoading: true, + isBuffering: true, + isPlaying: false, + position: startPosition, + bufferedPosition: Duration.zero, + duration: _fallbackDurationForItem(item), + clearError: true, + ); + unawaited(_savePlaybackSnapshot()); + + _updateMediaItemNotification(item); + + try { + if (expectedRequestEpoch != null && + !_isPlayRequestCurrent(expectedRequestEpoch)) { + return; + } + final isDirectLocalFile = uri.scheme == 'file'; + if (isDirectLocalFile) { + final filePath = uri.toFilePath(); + if (startPosition > Duration.zero) { + await _player.setFilePath(filePath, initialPosition: startPosition); + } else { + await _player.setFilePath(filePath); + } + } else { + if (startPosition > Duration.zero) { + await _player.setAudioSource( + AudioSource.uri(uri), + initialPosition: startPosition, + ); + } else { + await _player.setAudioSource(AudioSource.uri(uri)); + } + } + if (expectedRequestEpoch != null && + !_isPlayRequestCurrent(expectedRequestEpoch)) { + return; + } + await _player.play(); + } catch (e) { + if (expectedRequestEpoch != null && + !_isPlayRequestCurrent(expectedRequestEpoch)) { + return; + } + if (FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { + await FFmpegService.stopLiveDecryptedStream(); + } + if (FFmpegService.isActiveNativeDashManifestUrl(sourceUrl)) { + await FFmpegService.stopNativeDashManifestPlayback(); + } + _log.e('Failed to play source: $e'); + _setPlaybackError(e.toString(), type: 'playback_failed'); + rethrow; + } + } + + // ─── Lyrics fetching + parsing ─────────────────────────────────────────── + + Future _fetchLyricsForItem(PlaybackItem item) async { + final generation = ++_lyricsGeneration; + _log.d('Lyrics fetch start: ${item.artist} - ${item.title} (${item.id})'); + state = state.copyWith(lyricsLoading: true, clearLyrics: true); + + try { + final result = await PlatformBridge.fetchLyrics( + item.id, + item.title, + item.artist, + durationMs: item.durationMs, + ); + + // Discard if a newer track has started since + if (generation != _lyricsGeneration) return; + + final success = result['success'] == true; + final instrumental = result['instrumental'] == true; + final syncType = (result['sync_type'] as String?) ?? ''; + final source = (result['source'] as String?) ?? ''; + + if (!success && !instrumental) { + _log.d('Lyrics fetch returned no usable lyrics for ${item.id}'); + state = state.copyWith( + lyricsLoading: false, + lyrics: const LyricsData(), + ); + return; + } + + if (instrumental) { + _log.d('Lyrics fetch result is instrumental from: $source'); + state = state.copyWith( + lyricsLoading: false, + lyrics: LyricsData( + instrumental: true, + source: source, + syncType: syncType, + ), + ); + return; + } + + final rawLines = result['lines'] as List? ?? []; + final parsed = _parseLyricsLines(rawLines, syncType); + _log.d( + 'Lyrics fetch success from $source (sync=$syncType, lines=${parsed.lines.length}, wordSync=${parsed.hasWordSync})', + ); + + state = state.copyWith( + lyricsLoading: false, + lyrics: LyricsData( + lines: parsed.lines, + syncType: syncType, + source: source, + isWordSynced: parsed.hasWordSync, + ), + ); + } catch (e) { + if (generation != _lyricsGeneration) return; + _log.w('Lyrics fetch failed for ${item.id}: $e'); + state = state.copyWith(lyricsLoading: false, lyrics: const LyricsData()); + } + } + + /// Public method to manually refetch lyrics (e.g. retry button). + Future refetchLyrics() async { + await ensureLyricsLoaded(force: true); + } + + /// Load lyrics only when needed (e.g. when lyrics page is visible). + Future ensureLyricsLoaded({bool force = false}) async { + final item = state.currentItem; + if (item == null) return; + final lifecycleState = WidgetsBinding.instance.lifecycleState; + if (!force && + lifecycleState != null && + lifecycleState != AppLifecycleState.resumed) { + return; + } + if (!force) { + if (state.lyricsLoading) return; + if (state.lyrics != null) return; + } + await _fetchLyricsForItem(item); + } + + /// Parse raw lines from Go backend into [LyricsLine] list. + static ({List lines, bool hasWordSync}) _parseLyricsLines( + List rawLines, + String syncType, + ) { + final lines = []; + var hasAnyWordSync = false; + + for (var i = 0; i < rawLines.length; i++) { + final raw = rawLines[i] as Map; + final startMs = (raw['startTimeMs'] as num?)?.toInt() ?? 0; + final endMs = (raw['endTimeMs'] as num?)?.toInt() ?? 0; + final wordsRaw = (raw['words'] as String?) ?? ''; + + // Strip voice tags (v1:, v2:) from the beginning + var cleanedText = wordsRaw; + if (cleanedText.startsWith('v1:') || cleanedText.startsWith('v2:')) { + cleanedText = cleanedText.substring(3); + } + + // Parse word-by-word inline timestamps: word + final words = _parseInlineWordTimestamps(cleanedText, startMs); + if (words.isNotEmpty) hasAnyWordSync = true; + + // Clean text for display (remove inline timestamps) + final displayText = _stripInlineTimestamps(cleanedText); + + // Calculate end time: use provided endMs, or next line's start, or +5s + var effectiveEnd = endMs; + if (effectiveEnd <= startMs && i + 1 < rawLines.length) { + final nextStart = + (rawLines[i + 1] as Map)['startTimeMs'] as num?; + effectiveEnd = nextStart?.toInt() ?? (startMs + 5000); + } + if (effectiveEnd <= startMs) effectiveEnd = startMs + 5000; + + lines.add( + LyricsLine( + startMs: startMs, + endMs: effectiveEnd, + text: displayText.trim(), + words: words, + ), + ); + } + + return (lines: lines, hasWordSync: hasAnyWordSync); + } + + /// Parse inline `` timestamps in enhanced LRC word-by-word format. + static List _parseInlineWordTimestamps( + String text, + int lineStartMs, + ) { + // Pattern: or + final pattern = RegExp(r'<(\d{2}):(\d{2})\.(\d{2,3})>'); + final matches = pattern.allMatches(text).toList(); + if (matches.isEmpty) return []; + + final words = []; + + for (var i = 0; i < matches.length; i++) { + final match = matches[i]; + final startMs = _lrcInlineToMs( + match.group(1)!, + match.group(2)!, + match.group(3)!, + ); + + // Text runs from after this timestamp to the next timestamp (or end) + final textStart = match.end; + final textEnd = i + 1 < matches.length + ? matches[i + 1].start + : text.length; + final wordText = text.substring(textStart, textEnd); + + if (wordText.trim().isEmpty) continue; + + // End time is the start of the next word, or line end + buffer + final endMs = i + 1 < matches.length + ? _lrcInlineToMs( + matches[i + 1].group(1)!, + matches[i + 1].group(2)!, + matches[i + 1].group(3)!, + ) + : startMs + 2000; + + words.add(LyricsWord(text: wordText, startMs: startMs, endMs: endMs)); + } + + return words; + } + + static int _lrcInlineToMs(String min, String sec, String cs) { + final m = int.tryParse(min) ?? 0; + final s = int.tryParse(sec) ?? 0; + var c = int.tryParse(cs) ?? 0; + if (cs.length == 2) c *= 10; + return m * 60000 + s * 1000 + c; + } + + /// Remove inline timestamps like for clean display text. + static String _stripInlineTimestamps(String text) { + return text + .replaceAll(RegExp(r'<\d{2}:\d{2}\.\d{2,3}>'), '') + .replaceAll(RegExp(r'\[bg:.*?\]'), '') + .trim(); + } + + void _resetSmartQueueSessionState({required bool clearRecent}) { + _smartQueueRefillInFlight = false; + _lastSmartQueueRefillAt = null; + _smartQueueAutoAddedCount = 0; + _smartQueueSkipStreak = 0; + _smartQueueSessionProfile = const _SmartQueueSessionProfile( + mode: _SmartQueueSessionMode.balanced, + targetDurationSec: 215, + preferredSourceKey: '', + ); + _smartQueuePendingFeedbackByTrack.clear(); + _smartQueueSearchCache.clear(); + _smartQueueRelatedArtistsCache.clear(); + if (clearRecent) { + _recentPlayedTrackKeys.clear(); + _smartQueueSessionSignals.clear(); + _smartQueueTempoHintByTrackKey.clear(); + } + } + + bool _isSmartQueueEnabled() { + final settings = ref.read(settingsProvider); + if (!settings.smartQueueEnabled) return false; + if (state.repeatMode == RepeatMode.all || + state.repeatMode == RepeatMode.one) { + return false; + } + if (state.isLoading || state.currentIndex < 0 || state.queue.isEmpty) { + return false; + } + if (state.currentItem?.track == null) return false; + if (_smartQueueAutoAddedCount >= _smartQueueMaxAutoAddsPerSession) { + return false; + } + return true; + } + + String _normalizeSmartQueueKey(String value) => value.trim().toLowerCase(); + + String _trackKeyFromTrack(Track track) { + final isrc = _normalizeSmartQueueKey(track.isrc ?? ''); + if (isrc.isNotEmpty) return 'isrc:$isrc'; + + final source = _normalizeSmartQueueKey(track.source ?? ''); + final id = _normalizeSmartQueueKey(track.id); + if (source.isNotEmpty && id.isNotEmpty) return 'src:$source:$id'; + if (id.isNotEmpty) return 'id:$id'; + + final title = _normalizeSmartQueueKey(track.name); + final artist = _normalizeSmartQueueKey(track.artistName); + if (title.isNotEmpty || artist.isNotEmpty) { + return 'name:$title|$artist'; + } + return ''; + } + + String _trackKeyFromPlaybackItem(PlaybackItem item) { + final fromTrack = item.track; + if (fromTrack != null) { + final key = _trackKeyFromTrack(fromTrack); + if (key.isNotEmpty) return key; + } + + final id = _normalizeSmartQueueKey(item.id); + if (id.isNotEmpty) return 'id:$id'; + + final title = _normalizeSmartQueueKey(item.title); + final artist = _normalizeSmartQueueKey(item.artist); + if (title.isNotEmpty || artist.isNotEmpty) { + return 'name:$title|$artist'; + } + return ''; + } + + void _rememberRecentPlayed(PlaybackItem item) { + final key = _trackKeyFromPlaybackItem(item); + if (key.isEmpty) return; + _recentPlayedTrackKeys.remove(key); + _recentPlayedTrackKeys.insert(0, key); + if (_recentPlayedTrackKeys.length > _smartQueueRecentPlayedWindow) { + _recentPlayedTrackKeys.removeRange( + _smartQueueRecentPlayedWindow, + _recentPlayedTrackKeys.length, + ); + } + } + + void _learnFromCurrentTrackOutcome({required bool completedNaturally}) { + final current = state.currentItem; + if (current == null) return; + final key = _trackKeyFromPlaybackItem(current); + if (key.isEmpty) return; + + final durationMs = max( + 1, + state.duration.inMilliseconds > 0 + ? state.duration.inMilliseconds + : current.durationMs, + ); + final positionMs = state.position.inMilliseconds.clamp(0, durationMs); + final listenRatio = completedNaturally ? 1.0 : (positionMs / durationMs); + final skipStreakBefore = _smartQueueSkipStreak; + if (current.track != null) { + _recordSmartQueueSessionSignal( + track: current.track!, + listenRatio: listenRatio, + completedNaturally: completedNaturally, + ); + } + _updateSmartQueueSkipStreak( + listenRatio: listenRatio, + completedNaturally: completedNaturally, + ); + + final context = _smartQueuePendingFeedbackByTrack.remove(key); + if (context == null) return; + if (DateTime.now().difference(context.addedAt) > + _smartQueueFeedbackMaxAge) { + return; + } + + final hourBucket = _currentSmartQueueHourBucket(); + final reward = _smartQueueRewardFromListenRatio( + listenRatio: listenRatio, + completedNaturally: completedNaturally, + currentSkipStreak: skipStreakBefore, + hourAffinityRaw: _smartQueueHourAffinity[hourBucket] ?? 0.0, + ); + _updateSmartQueueModel( + features: context.features, + reward: reward, + track: current.track, + hourBucket: hourBucket, + ); + } + + double _smartQueueRewardFromListenRatio({ + required double listenRatio, + required bool completedNaturally, + required int currentSkipStreak, + required double hourAffinityRaw, + }) { + double reward; + if (completedNaturally || listenRatio >= 0.98) { + reward = 1.0; + } else if (listenRatio >= 0.75) { + reward = 0.85; + } else if (listenRatio >= 0.50) { + reward = 0.65; + } else if (listenRatio >= 0.25) { + reward = 0.35; + } else if (listenRatio >= 0.12) { + reward = 0.15; + } else { + reward = 0.0; + } + + // Contextual bandit shaping: adjust reward based on current context. + final hourAffinity = ((hourAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); + reward += (hourAffinity - 0.5) * 0.10; + if (!completedNaturally && listenRatio < 0.25 && currentSkipStreak >= 2) { + reward -= 0.08; + } + if (completedNaturally && currentSkipStreak >= 2) { + reward += 0.05; + } + return reward.clamp(0.0, 1.0); + } + + void _updateSmartQueueSkipStreak({ + required double listenRatio, + required bool completedNaturally, + }) { + if (completedNaturally || listenRatio >= 0.70) { + _smartQueueSkipStreak = 0; + return; + } + if (listenRatio < 0.35) { + _smartQueueSkipStreak = min( + _smartQueueMaxSkipStreak, + _smartQueueSkipStreak + 1, + ); + return; + } + _smartQueueSkipStreak = max(0, _smartQueueSkipStreak - 1); + } + + String _currentSmartQueueHourBucket() { + final hour = DateTime.now().hour; + return 'h${hour.toString().padLeft(2, '0')}'; + } + + void _recordSmartQueueSessionSignal({ + required Track track, + required double listenRatio, + required bool completedNaturally, + }) { + _smartQueueSessionSignals.add( + _SmartQueueSessionSignal( + artistKey: _normalizeSmartQueueKey(track.artistName), + sourceKey: _sourceKey(track.source ?? ''), + durationSec: max(1, track.duration), + releaseYear: _parseYear(track.releaseDate), + listenRatio: listenRatio.clamp(0.0, 1.0), + skipped: !completedNaturally && listenRatio < 0.70, + ), + ); + final maxSignals = _smartQueueSessionWindowSize * 6; + if (_smartQueueSessionSignals.length > maxSignals) { + _smartQueueSessionSignals.removeRange( + 0, + _smartQueueSessionSignals.length - maxSignals, + ); + } + } + + void _refreshSmartQueueSessionProfile({required Track seed}) { + final recent = + _smartQueueSessionSignals.length <= _smartQueueSessionWindowSize + ? List<_SmartQueueSessionSignal>.from(_smartQueueSessionSignals) + : _smartQueueSessionSignals.sublist( + _smartQueueSessionSignals.length - _smartQueueSessionWindowSize, + ); + if (recent.isEmpty) { + _smartQueueSessionProfile = _SmartQueueSessionProfile( + mode: _SmartQueueSessionMode.balanced, + targetDurationSec: max(140, seed.duration), + targetYear: _parseYear(seed.releaseDate), + preferredSourceKey: _sourceKey(seed.source ?? ''), + ); + return; + } + + final avgDuration = + recent.map((s) => s.durationSec.toDouble()).reduce((a, b) => a + b) / + recent.length; + final avgListen = + recent.map((s) => s.listenRatio).reduce((a, b) => a + b) / + recent.length; + final skipRate = recent.where((s) => s.skipped).length / recent.length; + final variance = + recent + .map((s) => pow((s.durationSec - avgDuration).toDouble(), 2)) + .reduce((a, b) => a + b) / + recent.length; + final durationStdDev = sqrt(variance); + + _SmartQueueSessionMode mode = _SmartQueueSessionMode.balanced; + if (skipRate > 0.45 || avgDuration < 190) { + mode = _SmartQueueSessionMode.energetic; + } else if (avgDuration > 280 && skipRate < 0.28) { + mode = _SmartQueueSessionMode.chill; + } else if (durationStdDev < 45 && avgListen >= 0.58) { + mode = _SmartQueueSessionMode.focus; + } + + final years = + recent + .map((s) => s.releaseYear) + .whereType() + .toList(growable: false) + ..sort(); + final targetYear = years.isEmpty + ? _parseYear(seed.releaseDate) + : years[years.length ~/ 2]; + final sourceCounts = {}; + for (final signal in recent) { + if (signal.sourceKey.isEmpty) continue; + sourceCounts[signal.sourceKey] = + (sourceCounts[signal.sourceKey] ?? 0) + 1; + } + var preferredSourceKey = _sourceKey(seed.source ?? ''); + if (sourceCounts.isNotEmpty) { + preferredSourceKey = + (sourceCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value))) + .first + .key; + } + final targetDurationSec = switch (mode) { + _SmartQueueSessionMode.chill => max(240, avgDuration.round()), + _SmartQueueSessionMode.focus => avgDuration.round().clamp(170, 320), + _SmartQueueSessionMode.energetic => avgDuration.round().clamp(120, 220), + _SmartQueueSessionMode.balanced => avgDuration.round().clamp(145, 280), + }; + + _smartQueueSessionProfile = _SmartQueueSessionProfile( + mode: mode, + targetDurationSec: targetDurationSec, + targetYear: targetYear, + preferredSourceKey: preferredSourceKey, + ); + } + + void _updateAffinity(Map map, String key, double reward) { + final normalizedKey = _normalizeSmartQueueKey(key); + if (normalizedKey.isEmpty) return; + + final current = map[normalizedKey] ?? 0.0; + final target = (reward * 2.0) - 1.0; // [0,1] -> [-1,1] + final updated = (current * 0.85) + (target * 0.15); + map[normalizedKey] = updated.clamp(-1.0, 1.0); + + while (map.length > _smartQueueMaxAffinityKeys) { + map.remove(map.keys.first); + } + } + + void _updateSmartQueueModel({ + required Map features, + required double reward, + Track? track, + required String hourBucket, + }) { + final clippedReward = reward.clamp(0.0, 1.0); + final prediction = _smartQueuePredict(features); + final error = clippedReward - prediction; + + final nextBias = + (_smartQueueWeights['bias'] ?? 0.0) + (_smartQueueLearningRate * error); + _smartQueueWeights['bias'] = nextBias.clamp(-3.0, 3.0); + + for (final entry in features.entries) { + final currentWeight = _smartQueueWeights[entry.key] ?? 0.0; + final updatedWeight = + currentWeight + (_smartQueueLearningRate * error * entry.value); + _smartQueueWeights[entry.key] = updatedWeight.clamp(-3.0, 3.0); + } + + if (track != null) { + _updateAffinity( + _smartQueueArtistAffinity, + track.artistName, + clippedReward, + ); + _updateAffinity( + _smartQueueSourceAffinity, + _sourceKey(track.source ?? ''), + clippedReward, + ); + _updateAffinity(_smartQueueHourAffinity, hourBucket, clippedReward); + } + + _scheduleSmartQueueModelSave(); + } + + double _smartQueuePredict(Map features) { + var logit = _smartQueueWeights['bias'] ?? 0.0; + for (final entry in features.entries) { + logit += (_smartQueueWeights[entry.key] ?? 0.0) * entry.value; + } + return _sigmoid(logit); + } + + double _sigmoid(double x) => 1.0 / (1.0 + exp(-x)); + + void _maybeTriggerSmartQueueRefill(Duration position) { + if (!_isSmartQueueEnabled()) return; + if (_smartQueueRefillInFlight) return; + + final remaining = state.queue.length - state.currentIndex - 1; + if (remaining > _smartQueueTriggerRemainingTracks) return; + if (position < const Duration(seconds: 8)) return; + + final lastRefill = _lastSmartQueueRefillAt; + if (lastRefill != null && + DateTime.now().difference(lastRefill) < _smartQueueRefillCooldown) { + return; + } + + unawaited(_autoRefillSmartQueue(force: false)); + } + + Future _autoRefillSmartQueue({required bool force}) async { + if (!_isSmartQueueEnabled()) return 0; + if (_smartQueueRefillInFlight) return 0; + + final remaining = max(0, state.queue.length - state.currentIndex - 1); + final needed = _smartQueueTargetRemainingTracks - remaining; + if (!force && needed <= 0) return 0; + + final lastRefill = _lastSmartQueueRefillAt; + if (!force && + lastRefill != null && + DateTime.now().difference(lastRefill) < _smartQueueRefillCooldown) { + return 0; + } + + final seed = state.currentItem?.track; + if (seed == null) return 0; + _refreshSmartQueueSessionProfile(seed: seed); + + final epoch = _playRequestEpoch; + _smartQueueRefillInFlight = true; + try { + _pruneSmartQueueCaches(); + + final candidates = await _fetchSmartQueueCandidates( + seed, + limit: _smartQueueCandidatePoolLimit, + ); + if (_playRequestEpoch != epoch) return 0; + if (candidates.isEmpty) return 0; + + final existingTrackKeys = {}; + for (final item in state.queue) { + final key = _trackKeyFromPlaybackItem(item); + if (key.isNotEmpty) existingTrackKeys.add(key); + } + existingTrackKeys.addAll(_recentPlayedTrackKeys); + + final scored = <_SmartQueueCandidate>[]; + for (final candidate in candidates) { + final candidateEntry = _buildSmartQueueCandidate( + seed: seed, + candidate: candidate, + existingTrackKeys: existingTrackKeys, + ); + if (candidateEntry == null) continue; + scored.add(candidateEntry); + } + if (scored.isEmpty) return 0; + + scored.sort((a, b) => b.score.compareTo(a.score)); + final targetCount = force ? max(1, needed) : max(0, needed); + if (targetCount <= 0) return 0; + final selected = _selectSmartQueueCandidates( + seed: seed, + sessionProfile: _smartQueueSessionProfile, + scored: scored, + targetCount: targetCount, + ); + if (selected.isEmpty) return 0; + if (_playRequestEpoch != epoch) return 0; + + final queueBefore = state.queue.length; + final updatedQueue = [...state.queue]; + for (final selection in selected) { + final item = _buildQueueItemFromTrack(selection.track); + updatedQueue.add(item); + final itemKey = _trackKeyFromPlaybackItem(item); + if (itemKey.isNotEmpty) { + _smartQueuePendingFeedbackByTrack[itemKey] = + _SmartQueueLearningContext( + features: selection.features, + addedAt: DateTime.now(), + ); + } + } + + state = state.copyWith(queue: updatedQueue); + if (state.shuffle) { + for (var idx = queueBefore; idx < updatedQueue.length; idx++) { + _shuffleOrder.add(idx); + } + } + + _smartQueueAutoAddedCount += selected.length; + _lastSmartQueueRefillAt = DateTime.now(); + unawaited(_savePlaybackSnapshot()); + final sourceSummary = {}; + for (final selection in selected) { + final source = _resolveSmartQueueSourceLabel(selection.track); + sourceSummary[source] = (sourceSummary[source] ?? 0) + 1; + } + final summaryText = sourceSummary.entries + .map((entry) => '${entry.key}:${entry.value}') + .join(', '); + _log.d( + 'Smart queue appended ${selected.length} tracks (remaining=$remaining, session=${_smartQueueSessionProfile.mode.name}, sources=[$summaryText])', + ); + return selected.length; + } catch (e) { + _log.d('Smart queue refill skipped: $e'); + return 0; + } finally { + _smartQueueRefillInFlight = false; + } + } + + Future> _fetchSmartQueueCandidates( + Track seed, { + required int limit, + }) async { + final queries = { + '${seed.artistName} ${seed.name}'.trim(), + seed.artistName.trim(), + '${seed.artistName} ${seed.albumName}'.trim(), + }.where((q) => q.isNotEmpty).take(3).toList(growable: false); + + if (queries.isEmpty) return const []; + + final perQueryLimit = max(10, (limit / queries.length).ceil() + 4); + final results = await Future.wait( + queries.map( + (q) => _searchTracksForSmartQueue(q, trackLimit: perQueryLimit), + ), + ); + + final merged = []; + for (final list in results) { + merged.addAll(list); + if (merged.length >= limit * 2) break; + } + + final relatedArtistTracks = await _fetchRelatedArtistTracksForSmartQueue( + seed, + fallbackTracks: merged, + limit: limit, + ); + if (relatedArtistTracks.isNotEmpty) { + merged.addAll(relatedArtistTracks); + } + return merged; + } + + Future> _fetchRelatedArtistTracksForSmartQueue( + Track seed, { + required List fallbackTracks, + required int limit, + }) async { + final seedArtist = _normalizeSmartQueueKey(seed.artistName); + if (seedArtist.isEmpty) return const []; + + final relatedArtists = await _discoverRelatedArtistsForSmartQueue( + seed, + fallbackTracks: fallbackTracks, + limit: _smartQueueRelatedArtistsLimit, + ); + if (relatedArtists.isEmpty) return const []; + + final perArtistLimit = max( + 6, + (limit / max(1, relatedArtists.length)).ceil(), + ); + final results = await Future.wait( + relatedArtists.map( + (artist) => + _searchTracksForSmartQueue(artist.name, trackLimit: perArtistLimit), + ), + ); + + final merged = []; + for (final tracks in results) { + for (final track in tracks) { + final artist = _normalizeSmartQueueKey(track.artistName); + if (artist.isEmpty || artist == seedArtist) continue; + merged.add(track); + } + if (merged.length >= limit) break; + } + return merged; + } + + Future> _discoverRelatedArtistsForSmartQueue( + Track seed, { + required List fallbackTracks, + required int limit, + }) async { + final seedArtist = _normalizeSmartQueueKey(seed.artistName); + if (seedArtist.isEmpty || limit <= 0) return const []; + + final cacheKey = 'seed:$seedArtist'; + final cached = _smartQueueRelatedArtistsCache[cacheKey]; + final now = DateTime.now(); + if (cached != null && + now.difference(cached.fetchedAt) < _smartQueueSearchCacheTtl) { + return cached.artists.take(limit).toList(growable: false); + } + + final relatedByName = {}; + void addCandidate(_SmartQueueRelatedArtist candidate) { + final key = _normalizeSmartQueueKey(candidate.name); + if (key.isEmpty || key == seedArtist) return; + final existing = relatedByName[key]; + if (existing == null || candidate.score > existing.score) { + relatedByName[key] = candidate; + } + } + + final spotifySeed = await _findArtistSeedBySearch( + queryArtistName: seed.artistName, + provider: 'spotify', + ); + if (spotifySeed != null) { + final related = await _fetchRelatedArtistsFromProviderSeed(spotifySeed); + for (final item in related) { + addCandidate(item); + } + } + + final deezerSeed = await _findArtistSeedBySearch( + queryArtistName: seed.artistName, + provider: 'deezer', + ); + if (deezerSeed != null) { + final related = await _fetchRelatedArtistsFromProviderSeed(deezerSeed); + for (final item in related) { + addCandidate(item); + } + } + + // Fallback heuristic from current track candidates if provider APIs don't return enough. + if (relatedByName.length < limit) { + final counts = {}; + for (final track in fallbackTracks.take(80)) { + final artistName = track.artistName.trim(); + final key = _normalizeSmartQueueKey(artistName); + if (key.isEmpty || key == seedArtist) continue; + counts[key] = (counts[key] ?? 0) + 1; + } + for (final entry in counts.entries) { + addCandidate( + _SmartQueueRelatedArtist( + name: entry.key, + provider: 'fallback', + score: min(1.0, 0.25 + (entry.value * 0.14)), + ), + ); + } + } + + final sorted = relatedByName.values.toList() + ..sort((a, b) => b.score.compareTo(a.score)); + _smartQueueRelatedArtistsCache[cacheKey] = _SmartQueueRelatedArtistsCache( + artists: sorted, + fetchedAt: now, + ); + return sorted.take(limit).toList(growable: false); + } + + Future<_SmartQueueArtistSeed?> _findArtistSeedBySearch({ + required String queryArtistName, + required String provider, + }) async { + final normalizedProvider = provider.trim().toLowerCase(); + final query = queryArtistName.trim(); + if (query.isEmpty) return null; + + final artists = await _searchArtistsForSmartQueue( + query: query, + provider: normalizedProvider, + limit: 8, + ); + if (artists.isEmpty) return null; + + artists.sort((a, b) => b.score.compareTo(a.score)); + return artists.first; + } + + Future> _fetchRelatedArtistsFromProviderSeed( + _SmartQueueArtistSeed seed, + ) async { + try { + if (seed.provider == 'spotify') { + return await _fetchSpotifyRelatedArtistsForSmartQueue(seed); + } else if (seed.provider == 'deezer') { + final response = await PlatformBridge.getDeezerRelatedArtists( + seed.id, + limit: 10, + ); + final rawList = response['artists'] as List? ?? const []; + final result = <_SmartQueueRelatedArtist>[]; + for (final entry in rawList) { + if (entry is! Map) continue; + final map = Map.from(entry); + final name = (map['name'] as String?)?.trim() ?? ''; + if (name.isEmpty) continue; + final popularity = (map['popularity'] as num?)?.toDouble() ?? 0.0; + final followers = (map['followers'] as num?)?.toDouble() ?? 0.0; + final score = + ((popularity / 100.0) * 0.65) + + (min(followers, 2000000) / 2000000.0) * 0.35; + result.add( + _SmartQueueRelatedArtist( + name: name, + provider: seed.provider, + score: score.clamp(0.05, 1.0), + ), + ); + } + return result; + } + return const []; + } catch (_) { + return const []; + } + } + + Future> + _fetchSpotifyRelatedArtistsForSmartQueue(_SmartQueueArtistSeed seed) async { + final seedArtistKey = _normalizeSmartQueueKey(seed.name); + if (seedArtistKey.isEmpty) return const []; + + final relatedScores = {}; + final relatedNames = {}; + + void addRelatedName(String rawName, double score) { + final name = rawName.trim(); + final key = _normalizeSmartQueueKey(name); + if (key.isEmpty || key == seedArtistKey || score <= 0) return; + relatedNames[key] = name; + relatedScores[key] = (relatedScores[key] ?? 0.0) + score; + } + + try { + final artist = await PlatformBridge.getArtistWithExtension( + _smartQueueSpotifyExtensionId, + seed.id, + ); + if (artist != null) { + final topTracks = artist['top_tracks'] as List? ?? const []; + for (var index = 0; index < topTracks.length && index < 20; index++) { + final entry = topTracks[index]; + if (entry is! Map) continue; + final map = Map.from(entry); + final artistsText = (map['artists'] ?? map['artist'] ?? '') + .toString() + .trim(); + if (artistsText.isEmpty) continue; + final rankWeight = (1.0 - (index / 18.0)).clamp(0.18, 1.0); + for (final artistName in _extractArtistNamesForSmartQueue( + artistsText, + )) { + addRelatedName(artistName, 0.42 * rankWeight); + } + } + } + } catch (_) {} + + try { + final searchResults = await PlatformBridge.customSearchWithExtension( + _smartQueueSpotifyExtensionId, + seed.name, + options: { + 'filter': 'artists', + 'limit': 12, + 'offset': 0, + }, + ); + for (var index = 0; index < searchResults.length; index++) { + final map = searchResults[index]; + final itemType = (map['item_type'] ?? '').toString().toLowerCase(); + if (itemType.isNotEmpty && itemType != 'artist') continue; + final id = (map['id'] ?? '').toString().trim(); + final name = (map['name'] ?? '').toString().trim(); + if (name.isEmpty) continue; + final normalizedName = _normalizeSmartQueueKey(name); + if (normalizedName == seedArtistKey || id == seed.id) continue; + + final similarity = _artistNameSimilarity(seed.name, name); + final rankWeight = (1.0 - (index / 12.0)).clamp(0.1, 1.0); + addRelatedName(name, (rankWeight * 0.24) + (similarity * 0.12)); + } + } catch (_) {} + + if (relatedScores.isEmpty) return const []; + + final related = <_SmartQueueRelatedArtist>[]; + for (final entry in relatedScores.entries) { + related.add( + _SmartQueueRelatedArtist( + name: relatedNames[entry.key] ?? entry.key, + provider: _smartQueueSpotifyExtensionId, + score: entry.value.clamp(0.05, 1.0), + ), + ); + } + related.sort((a, b) => b.score.compareTo(a.score)); + return related.take(10).toList(growable: false); + } + + List _extractArtistNamesForSmartQueue(String rawArtists) { + final tokens = splitArtistNames(rawArtists); + if (tokens.isEmpty) return const []; + + final names = []; + final seen = {}; + for (final token in tokens) { + final name = token.trim(); + if (name.isEmpty) continue; + final key = _normalizeSmartQueueKey(name); + if (key.isEmpty || !seen.add(key)) continue; + names.add(name); + } + return names; + } + + Future> _searchArtistsForSmartQueue({ + required String query, + required String provider, + int limit = 8, + }) async { + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty) return const []; + + final normalizedProvider = provider.trim().toLowerCase(); + if (normalizedProvider != 'spotify' && normalizedProvider != 'deezer') { + return const []; + } + + try { + final List> artistsRaw; + if (normalizedProvider == 'spotify') { + final response = await PlatformBridge.customSearchWithExtension( + _smartQueueSpotifyExtensionId, + normalizedQuery, + options: { + 'filter': 'artists', + 'limit': min(30, max(4, limit)), + 'offset': 0, + }, + ); + artistsRaw = response + .where( + (item) => + (item['item_type'] ?? 'artist').toString().toLowerCase() == + 'artist', + ) + .toList(growable: false); + } else { + final result = await PlatformBridge.searchDeezerAll( + normalizedQuery, + trackLimit: 1, + artistLimit: limit, + filter: 'artist', + ); + final raw = result['artists'] as List? ?? const []; + artistsRaw = raw + .whereType() + .map((entry) => Map.from(entry)) + .toList(growable: false); + } + + final seeds = <_SmartQueueArtistSeed>[]; + final seen = {}; + for (var index = 0; index < artistsRaw.length; index++) { + final map = artistsRaw[index]; + final id = (map['id'] ?? '').toString().trim(); + final name = (map['name'] ?? '').toString().trim(); + if (id.isEmpty || name.isEmpty) continue; + final key = '$normalizedProvider:${_normalizeSmartQueueKey(id)}'; + if (!seen.add(key)) continue; + + final popularity = (map['popularity'] as num?)?.toDouble() ?? 0.0; + final similarity = _artistNameSimilarity(query, name); + final rankScore = (1.0 - (index / max(1, artistsRaw.length))).clamp( + 0.05, + 1.0, + ); + final score = normalizedProvider == 'spotify' + ? (similarity * 0.82) + (rankScore * 0.18) + : (similarity * 0.72) + ((popularity / 100.0) * 0.28); + seeds.add( + _SmartQueueArtistSeed( + id: id, + name: name, + provider: normalizedProvider, + score: score.clamp(0.0, 1.0), + ), + ); + } + return seeds; + } catch (_) { + return const []; + } + } + + double _artistNameSimilarity(String a, String b) { + final na = _normalizeSmartQueueKey(a); + final nb = _normalizeSmartQueueKey(b); + if (na.isEmpty || nb.isEmpty) return 0.0; + if (na == nb) return 1.0; + if (na.contains(nb) || nb.contains(na)) return 0.88; + + final tokensA = na + .split(RegExp(r'[^a-z0-9]+')) + .where((t) => t.isNotEmpty) + .toSet(); + final tokensB = nb + .split(RegExp(r'[^a-z0-9]+')) + .where((t) => t.isNotEmpty) + .toSet(); + if (tokensA.isEmpty || tokensB.isEmpty) return 0.0; + + final intersection = tokensA.intersection(tokensB).length; + final union = tokensA.union(tokensB).length; + if (union == 0) return 0.0; + return intersection / union; + } + + Future> _searchTracksForSmartQueue( + String query, { + int trackLimit = 20, + }) async { + final normalizedQuery = _normalizeSmartQueueKey(query); + if (normalizedQuery.isEmpty) return const []; + + final now = DateTime.now(); + final cached = _smartQueueSearchCache[normalizedQuery]; + if (cached != null && + now.difference(cached.fetchedAt) < _smartQueueSearchCacheTtl) { + return cached.tracks; + } + + final settings = ref.read(settingsProvider); + final preferSpotify = + settings.metadataSource.trim().toLowerCase() == 'spotify'; + final primaryLimit = max( + trackLimit, + (trackLimit * _smartQueuePrimarySourceRatio).round() + 5, + ); + final secondaryLimit = max(trackLimit ~/ 2, trackLimit - 2); + + final primaryResults = await (preferSpotify + ? _safeSmartQueueTrackSearch( + () => _searchSpotifyTracksForSmartQueue( + normalizedQuery, + trackLimit: primaryLimit, + ), + ) + : _safeSmartQueueTrackSearch( + () => _searchDeezerTracksForSmartQueue( + normalizedQuery, + trackLimit: primaryLimit, + ), + )); + final shouldQuerySecondary = + primaryResults.length < + max(8, (trackLimit * _smartQueuePrimarySourceRatio).round()); + final secondaryResults = shouldQuerySecondary + ? (preferSpotify + ? await _safeSmartQueueTrackSearch( + () => _searchDeezerTracksForSmartQueue( + normalizedQuery, + trackLimit: secondaryLimit, + ), + ) + : await _safeSmartQueueTrackSearch( + () => _searchSpotifyTracksForSmartQueue( + normalizedQuery, + trackLimit: secondaryLimit, + ), + )) + : const >[]; + + final blended = _blendSmartQueueTrackCandidates( + primary: primaryResults, + secondary: secondaryResults, + targetCount: max(10, trackLimit + 6), + primaryRatio: _smartQueuePrimarySourceRatio, + ); + + final parsedTracks = []; + final seenTrackKeys = {}; + for (final entry in blended) { + final track = _parseSearchTrackForSmartQueue(entry); + if (track.id.trim().isEmpty || track.name.trim().isEmpty) continue; + if (track.isCollection) continue; + final key = _trackKeyFromTrack(track); + if (key.isNotEmpty && !seenTrackKeys.add(key)) continue; + _registerSmartQueueTrackHints(track: track, raw: entry); + parsedTracks.add(track); + } + + _smartQueueSearchCache[normalizedQuery] = _SmartQueueCachedResult( + tracks: parsedTracks, + fetchedAt: now, + ); + return parsedTracks; + } + + Future>> _safeSmartQueueTrackSearch( + Future>> Function() resolver, + ) async { + try { + return await resolver(); + } catch (e) { + _log.d('Smart queue source search failed: $e'); + return const >[]; + } + } + + List> _blendSmartQueueTrackCandidates({ + required List> primary, + required List> secondary, + required int targetCount, + required double primaryRatio, + }) { + final merged = >[]; + final seen = {}; + var primaryIndex = 0; + var secondaryIndex = 0; + var primaryTaken = 0; + var secondaryTaken = 0; + final maxTarget = max(1, targetCount); + + void tryTakeFrom(List> source, bool isPrimary) { + while (true) { + final index = isPrimary ? primaryIndex : secondaryIndex; + if (index >= source.length) return; + final item = source[index]; + if (isPrimary) { + primaryIndex++; + } else { + secondaryIndex++; + } + final dedupKey = _smartQueueRawTrackDedupKey(item); + if (dedupKey.isEmpty || !seen.add(dedupKey)) { + continue; + } + merged.add(item); + if (isPrimary) { + primaryTaken++; + } else { + secondaryTaken++; + } + return; + } + } + + while (merged.length < maxTarget && + (primaryIndex < primary.length || secondaryIndex < secondary.length)) { + final expectedPrimary = ((merged.length + 1) * primaryRatio).round(); + final shouldTakePrimary = + secondaryIndex >= secondary.length || + (primaryIndex < primary.length && primaryTaken < expectedPrimary); + if (shouldTakePrimary) { + tryTakeFrom(primary, true); + } else { + tryTakeFrom(secondary, false); + } + if (merged.length >= maxTarget) break; + if (primaryIndex >= primary.length && secondaryIndex < secondary.length) { + tryTakeFrom(secondary, false); + } else if (secondaryIndex >= secondary.length && + primaryIndex < primary.length) { + tryTakeFrom(primary, true); + } + if (primaryTaken + secondaryTaken == 0) { + break; + } + } + return merged; + } + + String _smartQueueRawTrackDedupKey(Map raw) { + final id = (raw['spotify_id'] ?? raw['id'] ?? '').toString().trim(); + final source = (raw['source'] ?? raw['provider_id'] ?? '') + .toString() + .trim(); + if (id.isNotEmpty && source.isNotEmpty) { + return 'src:${_normalizeSmartQueueKey(source)}:${_normalizeSmartQueueKey(id)}'; + } + if (id.isNotEmpty) { + return 'id:${_normalizeSmartQueueKey(id)}'; + } + final title = (raw['name'] ?? '').toString().trim(); + final artist = (raw['artists'] ?? raw['artist'] ?? '').toString().trim(); + if (title.isEmpty && artist.isEmpty) return ''; + return 'name:${_normalizeSmartQueueKey(title)}|${_normalizeSmartQueueKey(artist)}'; + } + + Future>> _searchSpotifyTracksForSmartQueue( + String query, { + required int trackLimit, + }) async { + final response = await PlatformBridge.customSearchWithExtension( + _smartQueueSpotifyExtensionId, + query, + options: { + 'filter': 'tracks', + 'limit': min(50, max(1, trackLimit)), + 'offset': 0, + }, + ); + return response + .where( + (item) => + (item['item_type'] ?? 'track').toString().toLowerCase() == + 'track', + ) + .toList(growable: false); + } + + Future>> _searchDeezerTracksForSmartQueue( + String query, { + required int trackLimit, + }) async { + final result = await PlatformBridge.searchDeezerAll( + query, + trackLimit: trackLimit, + artistLimit: 0, + filter: 'track', + ); + final tracks = result['tracks'] as List? ?? const []; + return tracks + .whereType() + .map((entry) { + final map = Map.from(entry); + map.putIfAbsent('provider_id', () => 'deezer'); + map.putIfAbsent('source', () => 'deezer'); + return map; + }) + .toList(growable: false); + } + + String _resolveSmartQueueSourceLabel(Track track) { + final raw = (track.source ?? '').trim().toLowerCase(); + if (raw.isNotEmpty) return raw; + final id = track.id.trim().toLowerCase(); + if (id.startsWith('deezer:')) return 'deezer'; + if (id.startsWith('spotify:')) return 'spotify'; + return 'unknown'; + } + + Track _parseSearchTrackForSmartQueue( + Map data, { + String? source, + }) { + final durationMs = _extractDurationMsForSmartQueue(data); + final itemType = data['item_type']?.toString(); + 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(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.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: + source ?? + data['source']?.toString() ?? + data['provider_id']?.toString(), + albumType: data['album_type']?.toString(), + itemType: itemType, + deezerId: data['deezer_id']?.toString(), + ); + } + + int _extractDurationMsForSmartQueue(Map data) { + final durationMsRaw = data['duration_ms']; + if (durationMsRaw is num && durationMsRaw > 0) { + return durationMsRaw.toInt(); + } + if (durationMsRaw is String) { + final parsed = num.tryParse(durationMsRaw.trim()); + if (parsed != null && parsed > 0) { + return parsed.toInt(); + } + } + + final durationSecRaw = data['duration']; + if (durationSecRaw is num && durationSecRaw > 0) { + return (durationSecRaw * 1000).toInt(); + } + if (durationSecRaw is String) { + final parsed = num.tryParse(durationSecRaw.trim()); + if (parsed != null && parsed > 0) { + return (parsed * 1000).toInt(); + } + } + return 0; + } + + void _registerSmartQueueTrackHints({ + required Track track, + required Map raw, + }) { + final tempo = _extractTempoBpmForSmartQueue(raw); + if (tempo == null || tempo <= 0) return; + final key = _trackKeyFromTrack(track); + if (key.isEmpty) return; + _smartQueueTempoHintByTrackKey[key] = tempo; + if (_smartQueueTempoHintByTrackKey.length > _smartQueueMaxTempoHints) { + final removeCount = + _smartQueueTempoHintByTrackKey.length - _smartQueueMaxTempoHints; + final keys = _smartQueueTempoHintByTrackKey.keys + .take(removeCount) + .toList(growable: false); + for (final k in keys) { + _smartQueueTempoHintByTrackKey.remove(k); + } + } + } + + double? _extractTempoBpmForSmartQueue(Map raw) { + const keys = ['tempo', 'bpm', 'audio_tempo', 'track_bpm']; + for (final key in keys) { + final value = raw[key]; + if (value is num) { + final bpm = value.toDouble(); + if (bpm > 30 && bpm < 260) return bpm; + } else if (value is String) { + final bpm = double.tryParse(value.trim()); + if (bpm != null && bpm > 30 && bpm < 260) return bpm; + } + } + return null; + } + + _SmartQueueCandidate? _buildSmartQueueCandidate({ + required Track seed, + required Track candidate, + required Set existingTrackKeys, + }) { + final candidateKey = _trackKeyFromTrack(candidate); + if (candidateKey.isEmpty || existingTrackKeys.contains(candidateKey)) { + return null; + } + + final features = _buildSmartQueueFeatures( + seed: seed, + candidate: candidate, + existingTrackKeys: existingTrackKeys, + ); + final prediction = _smartQueuePredict(features); + final exploration = + _smartQueueRandom.nextDouble() * _sessionExplorationCeiling(); + final score = prediction + exploration; + return _SmartQueueCandidate( + track: candidate, + key: candidateKey, + features: features, + score: score, + ); + } + + Map _buildSmartQueueFeatures({ + required Track seed, + required Track candidate, + required Set existingTrackKeys, + }) { + final sameArtist = + _normalizeSmartQueueKey(seed.artistName) == + _normalizeSmartQueueKey(candidate.artistName) + ? 1.0 + : 0.0; + final sameAlbum = + _normalizeSmartQueueKey(seed.albumName) == + _normalizeSmartQueueKey(candidate.albumName) + ? 1.0 + : 0.0; + final durationSimilarity = _durationSimilarity( + seed.duration, + candidate.duration, + ); + final sourceMatch = + _sourceKey(seed.source ?? '') == _sourceKey(candidate.source ?? '') + ? 1.0 + : 0.0; + final releaseYearSimilarity = _releaseYearSimilarity( + seed.releaseDate, + candidate.releaseDate, + ); + final artistAffinityRaw = + _smartQueueArtistAffinity[_normalizeSmartQueueKey( + candidate.artistName, + )] ?? + 0.0; + final sourceAffinityRaw = + _smartQueueSourceAffinity[_sourceKey(candidate.source ?? '')] ?? 0.0; + final artistAffinity = ((artistAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); + final sourceAffinity = ((sourceAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); + final sessionAlignment = _smartQueueSessionAlignment( + profile: _smartQueueSessionProfile, + candidate: candidate, + ); + final hourAffinityRaw = + _smartQueueHourAffinity[_currentSmartQueueHourBucket()] ?? 0.0; + final hourAffinity = ((hourAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); + final tempoContinuity = _smartQueueTempoContinuity( + seed: seed, + candidate: candidate, + ); + final yearCohesion = _smartQueueYearCohesion( + profile: _smartQueueSessionProfile, + candidate: candidate, + ); + + var artistRepetition = 0; + final candidateArtist = _normalizeSmartQueueKey(candidate.artistName); + if (candidateArtist.isNotEmpty) { + for (final key in _recentPlayedTrackKeys.take(10)) { + if (key.contains('|$candidateArtist')) { + artistRepetition++; + } + } + for (final queueItem in state.queue.reversed.take(6)) { + final artist = _normalizeSmartQueueKey(queueItem.artist); + if (artist.isNotEmpty && artist == candidateArtist) { + artistRepetition++; + } + } + } + final novelty = (1.0 - (artistRepetition / 3.0)).clamp(0.15, 1.0); + + final alreadySeen = existingTrackKeys.contains( + _trackKeyFromTrack(candidate), + ); + final noveltyAfterDuplicateCheck = alreadySeen ? 0.0 : novelty; + final skipPressure = (_smartQueueSkipStreak / _smartQueueMaxSkipStreak) + .clamp(0.0, 1.0); + final skipContext = (1.0 - (sameArtist * skipPressure)).clamp(0.05, 1.0); + + return { + 'same_artist': sameArtist, + 'same_album': sameAlbum, + 'duration_similarity': durationSimilarity, + 'source_match': sourceMatch, + 'release_year_similarity': releaseYearSimilarity, + 'artist_affinity': artistAffinity, + 'source_affinity': sourceAffinity, + 'novelty': noveltyAfterDuplicateCheck, + 'session_alignment': sessionAlignment, + 'hour_affinity': hourAffinity, + 'skip_context': skipContext, + 'tempo_continuity': tempoContinuity, + 'year_cohesion': yearCohesion, + }; + } + + double _durationSimilarity(int aSec, int bSec) { + if (aSec <= 0 || bSec <= 0) return 0.5; + final maxSec = max(aSec, bSec).toDouble(); + final diff = (aSec - bSec).abs().toDouble(); + final normalized = (1.0 - (diff / maxSec)).clamp(0.0, 1.0); + return normalized; + } + + double _releaseYearSimilarity(String? a, String? b) { + final yearA = _parseYear(a); + final yearB = _parseYear(b); + if (yearA == null || yearB == null) return 0.5; + final diff = (yearA - yearB).abs(); + if (diff == 0) return 1.0; + if (diff <= 1) return 0.85; + if (diff <= 3) return 0.65; + if (diff <= 6) return 0.45; + return 0.2; + } + + int? _parseYear(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + final match = RegExp(r'(\d{4})').firstMatch(raw); + if (match == null) return null; + return int.tryParse(match.group(1)!); + } + + double _sessionExplorationCeiling() { + return switch (_smartQueueSessionProfile.mode) { + _SmartQueueSessionMode.focus => 0.03, + _SmartQueueSessionMode.chill => 0.045, + _SmartQueueSessionMode.energetic => 0.08, + _SmartQueueSessionMode.balanced => 0.06, + }; + } + + double _smartQueueSessionAlignment({ + required _SmartQueueSessionProfile profile, + required Track candidate, + }) { + final targetDuration = max(1, profile.targetDurationSec); + final durationDiff = (candidate.duration - targetDuration).abs().toDouble(); + final durationMatch = + (1.0 - (durationDiff / max(90.0, targetDuration.toDouble()))).clamp( + 0.0, + 1.0, + ); + final yearMatch = _smartQueueYearCohesion( + profile: profile, + candidate: candidate, + ); + final preferredSource = _normalizeSmartQueueKey(profile.preferredSourceKey); + final candidateSource = _sourceKey(candidate.source ?? ''); + final sourceMatch = + preferredSource.isEmpty || candidateSource == preferredSource + ? 1.0 + : 0.45; + return ((durationMatch * 0.55) + (yearMatch * 0.25) + (sourceMatch * 0.20)) + .clamp(0.0, 1.0); + } + + double _smartQueueYearCohesion({ + required _SmartQueueSessionProfile profile, + required Track candidate, + }) { + final targetYear = profile.targetYear; + final candidateYear = _parseYear(candidate.releaseDate); + if (targetYear == null || candidateYear == null) return 0.55; + final diff = (targetYear - candidateYear).abs(); + if (diff == 0) return 1.0; + if (diff <= 2) return 0.88; + if (diff <= 5) return 0.72; + if (diff <= 10) return 0.5; + if (diff <= 15) return 0.3; + return 0.1; + } + + double _smartQueueTempoContinuity({ + required Track seed, + required Track candidate, + }) { + final seedTempo = _smartQueueTempoHintForTrack(seed); + final candidateTempo = _smartQueueTempoHintForTrack(candidate); + if (seedTempo == null || candidateTempo == null) { + return _durationSimilarity( + seed.duration, + candidate.duration, + ).clamp(0.2, 1.0); + } + final diff = (seedTempo - candidateTempo).abs(); + if (diff <= 8) return 1.0; + if (diff <= 16) return 0.82; + if (diff <= 26) return 0.62; + if (diff <= _smartQueueMaxTempoJumpBpm) return 0.38; + return 0.12; + } + + double? _smartQueueTempoHintForTrack(Track track) { + final key = _trackKeyFromTrack(track); + if (key.isEmpty) return null; + final raw = _smartQueueTempoHintByTrackKey[key]; + if (raw == null || raw <= 0) return null; + return raw; + } + + String _sourceKey(String sourceRaw) { + final normalized = _normalizeSmartQueueKey(sourceRaw); + if (normalized.isNotEmpty) return normalized; + return _resolveService( + ref.read(settingsProvider).defaultService, + ).toLowerCase(); + } + + List<_SmartQueueCandidate> _selectSmartQueueCandidates({ + required Track seed, + required _SmartQueueSessionProfile sessionProfile, + required List<_SmartQueueCandidate> scored, + required int targetCount, + }) { + if (targetCount <= 0 || scored.isEmpty) return const []; + + final poolSize = min(scored.length, max(14, targetCount * 3)); + final pool = scored.take(poolSize).toList(growable: true); + final selected = <_SmartQueueCandidate>[]; + final artistCounts = _buildSmartQueueArtistBaselineCounts(); + final selectedKeys = {}; + + while (pool.isNotEmpty && selected.length < targetCount) { + final picked = _pickWeightedCandidate(pool); + pool.remove(picked); + if (selectedKeys.contains(picked.key)) { + continue; + } + + final artistKey = _normalizeSmartQueueKey(picked.track.artistName); + final repeats = artistCounts[artistKey] ?? 0; + if (artistKey.isNotEmpty && repeats >= _smartQueueMaxArtistRepeats) { + continue; + } + + if (!_passesSmartQueueConstraints( + seed: seed, + candidate: picked.track, + profile: sessionProfile, + )) { + continue; + } + + selected.add(picked); + selectedKeys.add(picked.key); + if (artistKey.isNotEmpty) { + artistCounts[artistKey] = repeats + 1; + } + } + + if (selected.isEmpty) { + final relaxedArtistLimit = _smartQueueMaxArtistRepeats + 1; + for (final candidate in scored) { + if (selected.length >= targetCount) break; + if (selectedKeys.contains(candidate.key)) continue; + + final artistKey = _normalizeSmartQueueKey(candidate.track.artistName); + final repeats = artistCounts[artistKey] ?? 0; + if (artistKey.isNotEmpty && repeats >= relaxedArtistLimit) { + continue; + } + + selected.add(candidate); + selectedKeys.add(candidate.key); + if (artistKey.isNotEmpty) { + artistCounts[artistKey] = repeats + 1; + } + } + } + + return selected; + } + + Map _buildSmartQueueArtistBaselineCounts() { + final counts = {}; + for (final item in state.queue.reversed.take(8)) { + final artistKey = _normalizeSmartQueueKey(item.artist); + if (artistKey.isEmpty) continue; + counts[artistKey] = (counts[artistKey] ?? 0) + 1; + } + for (final signal in _smartQueueSessionSignals.reversed.take(8)) { + final artistKey = signal.artistKey; + if (artistKey.isEmpty) continue; + counts[artistKey] = (counts[artistKey] ?? 0) + 1; + } + return counts; + } + + bool _passesSmartQueueConstraints({ + required Track seed, + required Track candidate, + required _SmartQueueSessionProfile profile, + }) { + final seedYear = _parseYear(seed.releaseDate); + final candidateYear = _parseYear(candidate.releaseDate); + if (seedYear != null && + candidateYear != null && + (seedYear - candidateYear).abs() > _smartQueueMaxDecadeDriftYears) { + return false; + } + + if (profile.targetYear != null && + candidateYear != null && + (profile.targetYear! - candidateYear).abs() > + _smartQueueMaxDecadeDriftYears) { + return false; + } + + final seedTempo = _smartQueueTempoHintForTrack(seed); + final candidateTempo = _smartQueueTempoHintForTrack(candidate); + if (seedTempo != null && + candidateTempo != null && + (seedTempo - candidateTempo).abs() > _smartQueueMaxTempoJumpBpm) { + return false; + } + + final seedDuration = max(1, seed.duration); + final candidateDuration = max(1, candidate.duration); + final durationRatio = candidateDuration / seedDuration; + if (durationRatio > 2.25 || durationRatio < 0.45) { + return false; + } + return true; + } + + _SmartQueueCandidate _pickWeightedCandidate(List<_SmartQueueCandidate> pool) { + if (pool.length == 1) return pool.first; + + var total = 0.0; + for (final item in pool) { + total += max(0.0001, item.score); + } + var cursor = _smartQueueRandom.nextDouble() * total; + for (final item in pool) { + cursor -= max(0.0001, item.score); + if (cursor <= 0) return item; + } + return pool.last; + } + + void _pruneSmartQueueCaches() { + final now = DateTime.now(); + _smartQueueSearchCache.removeWhere( + (_, value) => now.difference(value.fetchedAt) > _smartQueueSearchCacheTtl, + ); + _smartQueueRelatedArtistsCache.removeWhere( + (_, value) => now.difference(value.fetchedAt) > _smartQueueSearchCacheTtl, + ); + _smartQueuePendingFeedbackByTrack.removeWhere( + (_, value) => now.difference(value.addedAt) > _smartQueueFeedbackMaxAge, + ); + } + + Uri _uriFromPath(String path) { + final input = path.trim(); + if (input.startsWith('http://') || + input.startsWith('https://') || + input.startsWith('content://') || + input.startsWith('file://')) { + return Uri.parse(input); + } + return Uri.file(input); + } + + String _resolvePrefetchServiceBucket(PlaybackItem item) { + final itemService = item.service.trim().toLowerCase(); + if (_isBuiltInStreamingService(itemService)) { + return itemService; + } + + final trackSource = (item.track?.source ?? '').trim().toLowerCase(); + if (_isBuiltInStreamingService(trackSource)) { + return trackSource; + } + + final defaultService = _resolveService( + ref.read(settingsProvider).defaultService, + ).toLowerCase(); + if (_isBuiltInStreamingService(defaultService)) { + return defaultService; + } + return 'other'; + } + + int _defaultPrefetchResolveLatencyMs(String serviceBucket) { + switch (serviceBucket) { + case 'tidal': + return 16000; + case 'amazon': + return 15000; + case 'qobuz': + return 10000; + case 'youtube': + return 12000; + default: + return 10000; + } + } + + int _prefetchSafetyMarginMs(String serviceBucket) { + switch (serviceBucket) { + case 'tidal': + return 9000; + case 'amazon': + return 7000; + case 'qobuz': + return 5000; + case 'youtube': + return 6000; + default: + return 5000; + } + } + + int _estimatePrefetchResolveLatencyMs(String serviceBucket) { + final samples = _prefetchLatencyByServiceMs[serviceBucket]; + if (samples == null || samples.isEmpty) { + return _defaultPrefetchResolveLatencyMs(serviceBucket); + } + + final sorted = [...samples]..sort(); + final percentileIndex = (((sorted.length - 1) * 0.95).round()).clamp( + 0, + sorted.length - 1, + ); + return sorted[percentileIndex]; + } + + Duration _adaptivePrefetchThresholdFor(PlaybackItem nextItem) { + final serviceBucket = _resolvePrefetchServiceBucket(nextItem); + var triggerMs = + _estimatePrefetchResolveLatencyMs(serviceBucket) + + _prefetchSafetyMarginMs(serviceBucket); + if (serviceBucket == 'tidal') { + // DASH manifest flow typically needs earlier warmup than direct URLs. + triggerMs = max(triggerMs, 22000); + } + final clamped = triggerMs.clamp( + _prefetchThresholdFloor.inMilliseconds, + _prefetchThresholdCeiling.inMilliseconds, + ); + return Duration(milliseconds: clamped.toInt()); + } + + bool _shouldTriggerPrefetchAttempt({ + required int attempts, + required Duration position, + required Duration remaining, + required Duration threshold, + }) { + if (attempts >= _maxPrefetchAttemptsPerTrack) { + return false; + } + if (position < const Duration(seconds: 1) || remaining.isNegative) { + return false; + } + + final inLateWindow = remaining <= threshold; + if (attempts == 0) { + return inLateWindow || position >= _prefetchEarlyKickoffPosition; + } + + // Retry only close to track end to avoid repeated resolver load. + return inLateWindow; + } + + void _maybePrefetchNext(Duration position) { + if (state.isLoading || state.currentIndex < 0 || state.queue.isEmpty) { + return; + } + final duration = state.duration; + if (duration <= Duration.zero) return; + + final nextIndex = _peekNextIndexForPrefetch(); + if (nextIndex == null) return; + if (nextIndex < 0 || nextIndex >= state.queue.length) return; + if (_prefetchingQueueIndex == nextIndex && + _lastPrefetchAttemptIndex == nextIndex) { + return; + } + + final nextItem = state.queue[nextIndex]; + if (nextItem.sourceUri.isNotEmpty || + nextItem.track == null || + nextItem.isLocal) { + return; + } + + final remaining = duration - position; + final adaptiveThreshold = _adaptivePrefetchThresholdFor(nextItem); + final attempts = _prefetchAttemptCounts[nextIndex] ?? 0; + if (!_shouldTriggerPrefetchAttempt( + attempts: attempts, + position: position, + remaining: remaining, + threshold: adaptiveThreshold, + )) { + return; + } + + final lastAttemptAt = _prefetchLastAttemptAt[nextIndex]; + if (lastAttemptAt != null && + DateTime.now().difference(lastAttemptAt) < _prefetchRetryCooldown) { + return; + } + + _prefetchAttemptCounts[nextIndex] = attempts + 1; + _prefetchLastAttemptAt[nextIndex] = DateTime.now(); + _lastPrefetchAttemptIndex = nextIndex; + unawaited(_prefetchQueueIndex(nextIndex)); + } + + int? _peekNextIndexForPrefetch() { + if (state.queue.isEmpty) return null; + + if (state.shuffle) { + final nextPos = _shufflePosition + 1; + if (nextPos < _shuffleOrder.length) { + return _shuffleOrder[nextPos]; + } + if (state.repeatMode == RepeatMode.all && _shuffleOrder.isNotEmpty) { + return _shuffleOrder.first; + } + return null; + } + + final next = state.currentIndex + 1; + if (next < state.queue.length) return next; + if (state.repeatMode == RepeatMode.all) return 0; + return null; + } + + Future _prefetchQueueIndex(int index) async { + if (index < 0) return; + } + + String _resolveService(String defaultService) { + final selected = defaultService.trim(); + if (selected.isEmpty) { + return 'tidal'; + } + final normalized = selected.toLowerCase(); + if (_isBuiltInStreamingService(normalized)) { + return normalized; + } + return selected; + } + + bool _isBuiltInStreamingService(String service) { + switch (service) { + case 'tidal': + case 'qobuz': + case 'amazon': + case 'youtube': + return true; + default: + return false; + } + } + + void _setPlaybackError(String message, {String type = 'resolve_failed'}) { + final trimmed = message.trim(); + state = state.copyWith( + isLoading: false, + isPlaying: false, + isBuffering: false, + error: trimmed.isEmpty ? 'Playback error' : trimmed, + errorType: type, + ); + } + + bool _shouldAutoSkipQueueItemOnFailure(String? failureType) { + final settings = ref.read(settingsProvider); + if (!settings.autoSkipUnavailableTracks) { + return false; + } + final normalized = (failureType ?? '').trim().toLowerCase(); + return normalized == 'not_found' || normalized == 'resolve_failed'; + } + + int? _resolveNextQueueIndexWithoutWrapAfterFailure(int failedIndex) { + if (failedIndex < 0 || failedIndex >= state.queue.length) return null; + + if (state.shuffle) { + final failedShufflePos = _shuffleOrder.indexOf(failedIndex); + if (failedShufflePos < 0) return null; + final nextShufflePos = failedShufflePos + 1; + if (nextShufflePos >= _shuffleOrder.length) return null; + return _shuffleOrder[nextShufflePos]; + } + + final nextIndex = failedIndex + 1; + if (nextIndex >= state.queue.length) return null; + return nextIndex; + } + + Future _handleQueueItemPlaybackFailure({ + required int failedIndex, + required int expectedRequestEpoch, + required Object error, + String fallbackType = 'resolve_failed', + }) async { + if (!_isPlayRequestCurrent(expectedRequestEpoch)) { + return false; + } + + final hasExistingError = (state.error ?? '').trim().isNotEmpty; + if (hasExistingError) { + state = state.copyWith( + isLoading: false, + isPlaying: false, + isBuffering: false, + ); + } else { + _setPlaybackError('Failed to play: $error', type: fallbackType); + } + + if (!_isPlayRequestCurrent(expectedRequestEpoch) || + state.currentIndex != failedIndex || + !_shouldAutoSkipQueueItemOnFailure(state.errorType)) { + return false; + } + + final nextIndex = _resolveNextQueueIndexWithoutWrapAfterFailure( + failedIndex, + ); + if (nextIndex == null || nextIndex == failedIndex) { + return false; + } + + final failureMessage = (state.error ?? '').trim(); + _log.w( + 'Auto-skip queue item $failedIndex -> $nextIndex ' + 'after ${state.errorType ?? fallbackType}: ' + '${failureMessage.isNotEmpty ? failureMessage : error}', + ); + await _playQueueIndex(nextIndex); + return true; + } + + bool _inferSeekSupportedForQueueItem(PlaybackItem item) { + if (item.isLocal) return true; + + final service = item.service.trim().toLowerCase(); + final trackSource = (item.track?.source ?? '').trim().toLowerCase(); + final resolvedService = service.isNotEmpty ? service : trackSource; + if (resolvedService == 'youtube') return false; + + final sourceUri = item.sourceUri.trim(); + if (sourceUri.isNotEmpty && + FFmpegService.isActiveLiveDecryptedUrl(sourceUri)) { + return false; + } + + return true; + } + + Duration? _pendingResumePositionForIndex(int index) { + final pendingPosition = _pendingResumePosition; + final pendingIndex = _pendingResumeIndex; + if (pendingPosition == null || + pendingPosition <= Duration.zero || + pendingIndex != index) { + return null; + } + return pendingPosition; + } + + void _clearPendingResumeForIndex(int index) { + if (_pendingResumeIndex != index) return; + _pendingResumePosition = null; + _pendingResumeIndex = null; + } + + void _scheduleSnapshotSaveForProgress(Duration position) { + if (state.queue.isEmpty || state.currentIndex < 0) return; + if (_player.processingState == ProcessingState.idle) return; + + final ms = position.inMilliseconds; + if (_lastProgressSnapshotMs >= 0 && + (ms - _lastProgressSnapshotMs).abs() < 1500) { + return; + } + _lastProgressSnapshotMs = ms; + + _snapshotSaveTimer?.cancel(); + _snapshotSaveTimer = Timer(const Duration(milliseconds: 300), () { + unawaited(_savePlaybackSnapshot()); + }); + } + + void _disposeInternal() { + _appLifecycleListener?.dispose(); + _appLifecycleListener = null; + _snapshotSaveTimer?.cancel(); + _smartQueueModelSaveTimer?.cancel(); + unawaited(_savePlaybackSnapshot()); + unawaited(_persistSmartQueueModel()); + unawaited(FFmpegService.stopLiveDecryptedStream()); + unawaited(FFmpegService.stopNativeDashManifestPlayback()); + for (final sub in _subscriptions) { + sub.cancel(); + } + _player.dispose(); + } +} + +class _SmartQueueLearningContext { + final Map features; + final DateTime addedAt; + + const _SmartQueueLearningContext({ + required this.features, + required this.addedAt, + }); +} + +enum _SmartQueueSessionMode { balanced, focus, chill, energetic } + +class _SmartQueueSessionProfile { + final _SmartQueueSessionMode mode; + final int targetDurationSec; + final int? targetYear; + final String preferredSourceKey; + + const _SmartQueueSessionProfile({ + required this.mode, + required this.targetDurationSec, + this.targetYear, + this.preferredSourceKey = '', + }); +} + +class _SmartQueueSessionSignal { + final String artistKey; + final String sourceKey; + final int durationSec; + final int? releaseYear; + final double listenRatio; + final bool skipped; + + const _SmartQueueSessionSignal({ + required this.artistKey, + required this.sourceKey, + required this.durationSec, + required this.releaseYear, + required this.listenRatio, + required this.skipped, + }); +} + +class _SmartQueueCachedResult { + final List tracks; + final DateTime fetchedAt; + + const _SmartQueueCachedResult({ + required this.tracks, + required this.fetchedAt, + }); +} + +class _SmartQueueRelatedArtistsCache { + final List<_SmartQueueRelatedArtist> artists; + final DateTime fetchedAt; + + const _SmartQueueRelatedArtistsCache({ + required this.artists, + required this.fetchedAt, + }); +} + +class _SmartQueueRelatedArtist { + final String name; + final String provider; + final double score; + + const _SmartQueueRelatedArtist({ + required this.name, + required this.provider, + required this.score, + }); +} + +class _SmartQueueArtistSeed { + final String id; + final String name; + final String provider; + final double score; + + const _SmartQueueArtistSeed({ + required this.id, + required this.name, + required this.provider, + required this.score, + }); +} + +class _SmartQueueCandidate { + final Track track; + final String key; + final Map features; + final double score; + + const _SmartQueueCandidate({ + required this.track, + required this.key, + required this.features, + required this.score, + }); +} + +final playbackProvider = NotifierProvider( + PlaybackController.new, +); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f293d005..18a6a9f0 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -3,12 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 2; +const _currentMigrationVersion = 4; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); @@ -93,6 +94,18 @@ class SettingsNotifier extends Notifier { if (!state.isFirstLaunch && !state.hasCompletedTutorial) { state = state.copyWith(hasCompletedTutorial: true); } + // Migration 4: include Spotify Lyrics API in provider order for existing users + if (!state.lyricsProviders.contains('spotify_api')) { + final updatedProviders = List.from(state.lyricsProviders); + final lrclibIndex = updatedProviders.indexOf('lrclib'); + if (lrclibIndex >= 0) { + updatedProviders.insert(lrclibIndex + 1, 'spotify_api'); + } else { + updatedProviders.add('spotify_api'); + } + state = state.copyWith(lyricsProviders: updatedProviders); + } + state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } @@ -266,11 +279,26 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAutoSkipUnavailableTracks(bool enabled) { + state = state.copyWith(autoSkipUnavailableTracks: enabled); + _saveSettings(); + } + + void setSmartQueueEnabled(bool enabled) { + state = state.copyWith(smartQueueEnabled: enabled); + _saveSettings(); + } + void setEmbedLyrics(bool enabled) { state = state.copyWith(embedLyrics: enabled); _saveSettings(); } + void setEmbedMetadata(bool enabled) { + state = state.copyWith(embedMetadata: enabled); + _saveSettings(); + } + void setLyricsMode(String mode) { if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 3ecc340e..32022b81 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -551,6 +551,7 @@ class TrackNotifier extends Notifier { state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); @@ -713,6 +714,7 @@ class TrackNotifier extends Notifier { searchPlaylists: playlists, isLoading: false, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, // Preserve filter in results ); } catch (e, stackTrace) { @@ -722,6 +724,7 @@ class TrackNotifier extends Notifier { isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); } @@ -737,6 +740,7 @@ class TrackNotifier extends Notifier { state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading ); @@ -776,6 +780,7 @@ class TrackNotifier extends Notifier { searchArtists: [], isLoading: false, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, searchExtensionId: extensionId, // Store which extension was used selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter @@ -787,6 +792,7 @@ class TrackNotifier extends Notifier { isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, ); } } @@ -808,6 +814,8 @@ class TrackNotifier extends Notifier { artistName: track.artistName, albumName: track.albumName, albumArtist: track.albumArtist, + artistId: track.artistId, + albumId: track.albumId, coverUrl: track.coverUrl, isrc: track.isrc, duration: track.duration, @@ -876,19 +884,23 @@ class TrackNotifier extends Notifier { playlistName: playlistName, coverUrl: coverUrl, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, ); } Track _parseTrack(Map data) { + final durationMs = _extractDurationMs(data); return Track( id: data['spotify_id'] as String? ?? '', name: data['name'] as String? ?? '', artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, - duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), + duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, @@ -896,13 +908,7 @@ class TrackNotifier extends Notifier { } Track _parseSearchTrack(Map data, {String? source}) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } + final durationMs = _extractDurationMs(data); final itemType = data['item_type']?.toString(); @@ -912,6 +918,8 @@ class TrackNotifier extends Notifier { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -927,6 +935,32 @@ class TrackNotifier extends Notifier { ); } + int _extractDurationMs(Map data) { + final durationMsRaw = data['duration_ms']; + if (durationMsRaw is num && durationMsRaw > 0) { + return durationMsRaw.toInt(); + } + if (durationMsRaw is String) { + final parsed = num.tryParse(durationMsRaw.trim()); + if (parsed != null && parsed > 0) { + return parsed.toInt(); + } + } + + final durationSecRaw = data['duration']; + if (durationSecRaw is num && durationSecRaw > 0) { + return (durationSecRaw * 1000).toInt(); + } + if (durationSecRaw is String) { + final parsed = num.tryParse(durationSecRaw.trim()); + if (parsed != null && parsed > 0) { + return (parsed * 1000).toInt(); + } + } + + return 0; + } + ArtistAlbum _parseArtistAlbum(Map data) { return ArtistAlbum( id: data['id'] as String? ?? '', diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 7e5d7d1c..a6516270 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -14,9 +14,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; -import 'package:spotiflac_android/screens/artist_screen.dart'; -import 'package:spotiflac_android/screens/home_tab.dart' - show ExtensionArtistScreen; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class _AlbumCache { static final Map _cache = {}; @@ -187,7 +185,8 @@ class _AlbumScreenState extends ConsumerState { .toList(); final albumInfo = metadata['album_info'] as Map?; - final artistId = albumInfo?['artist_id'] as String?; + final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) + ?.toString(); _AlbumCache.set(widget.albumId, tracks); @@ -215,6 +214,9 @@ class _AlbumScreenState extends ConsumerState { artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), @@ -368,19 +370,19 @@ class _AlbumScreenState extends ConsumerState { ), if (artistName != null && artistName.isNotEmpty) ...[ const SizedBox(height: 6), - GestureDetector( - onTap: () => _navigateToArtist(context, artistName), - child: Text( - artistName, - style: TextStyle( - color: colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ClickableArtistName( + artistName: artistName, + artistId: _artistId, + coverUrl: widget.coverUrl, + extensionId: widget.extensionId, + style: TextStyle( + color: colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], if (tracks.isNotEmpty) ...[ @@ -459,7 +461,7 @@ class _AlbumScreenState extends ConsumerState { const SizedBox(width: 12), FilledButton.icon( onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), + icon: Icon(Icons.download, size: 18), label: Text( context.l10n.downloadAllCount(tracks.length), ), @@ -608,8 +610,9 @@ class _AlbumScreenState extends ConsumerState { ), ), child: IconButton( - onPressed: - tracks == null || tracks.isEmpty ? null : () => _loveAll(tracks), + onPressed: tracks == null || tracks.isEmpty + ? null + : () => _loveAll(tracks), icon: Icon( allLoved ? Icons.favorite : Icons.favorite_border, size: 22, @@ -634,10 +637,9 @@ class _AlbumScreenState extends ConsumerState { ), ), child: IconButton( - onPressed: - _tracks == null || _tracks!.isEmpty - ? null - : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), + onPressed: _tracks == null || _tracks!.isEmpty + ? null + : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), icon: const Icon(Icons.add, size: 22, color: Colors.white), tooltip: 'Add to Playlist', padding: EdgeInsets.zero, @@ -657,9 +659,7 @@ class _AlbumScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Removed ${tracks.length} tracks from Loved'), - ), + SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), ); } } else { @@ -672,55 +672,12 @@ class _AlbumScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Added $addedCount tracks to Loved'), - ), + SnackBar(content: Text('Added $addedCount tracks to Loved')), ); } } } - void _navigateToArtist(BuildContext context, String artistName) { - final artistId = - _artistId ?? - (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); - - if (artistId == 'unknown' || - artistId == 'deezer:unknown' || - artistId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Artist information not available')), - ); - return; - } - - if (widget.extensionId != null) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionArtistScreen( - extensionId: widget.extensionId!, - artistId: artistId, - artistName: artistName, - coverUrl: widget.coverUrl, - ), - ), - ); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ArtistScreen( - artistId: artistId, - artistName: artistName, - coverUrl: widget.coverUrl, - ), - ), - ); - } - Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -860,8 +817,10 @@ class _AlbumTrackItem extends ConsumerWidget { subtitle: Row( children: [ Flexible( - child: Text( - track.artistName, + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), @@ -909,6 +868,11 @@ class _AlbumTrackItem extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), ), ), ); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 76c9973d..420ee857 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; /// Simple in-memory cache for artist data class _ArtistCache { @@ -309,6 +310,10 @@ class _ArtistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + widget.artistId, + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -675,6 +680,7 @@ class _ArtistScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -772,7 +778,6 @@ class _ArtistScreenState extends ConsumerState { List albums, ) async { final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -990,6 +995,8 @@ class _ArtistScreenState extends ConsumerState { .toString(), albumName: album.name, albumArtist: widget.artistName, + artistId: widget.artistId, + albumId: album.id.isNotEmpty ? album.id : null, coverUrl: album.coverUrl, isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -1110,17 +1117,18 @@ class _ArtistScreenState extends ConsumerState { 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), + 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, ), @@ -1128,16 +1136,19 @@ class _ArtistScreenState extends ConsumerState { 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), + 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, + ), + ), + ], ), - ], - ), ), ], ], @@ -1263,6 +1274,11 @@ class _ArtistScreenState extends ConsumerState { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -1329,8 +1345,12 @@ class _ArtistScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), if (track.albumName.isNotEmpty) - Text( - track.albumName, + ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, + extensionId: widget.extensionId, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, @@ -1339,9 +1359,7 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - TrackCollectionQuickActions( - track: track, - ), + TrackCollectionQuickActions(track: track), ], ), ), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 3187aed0..0db150eb 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -267,9 +268,17 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - Future _openFile(String filePath) async { + Future _openFile(DownloadHistoryItem track) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: track.filePath, + title: track.trackName, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -849,7 +858,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - onPressed: () => _openFile(track.filePath), + onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( @@ -915,6 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 2066e08e..6462d952 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -26,6 +26,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -64,6 +65,121 @@ class _CsvImportOptions { }); } +class _SearchResultBuckets { + final List realTracks; + final List realTrackIndexes; + final List albumItems; + final List playlistItems; + final List artistItems; + + const _SearchResultBuckets({ + required this.realTracks, + required this.realTrackIndexes, + required this.albumItems, + required this.playlistItems, + required this.artistItems, + }); +} + +_RecentAccessView _buildRecentAccessViewData( + List items, + List historyItems, + Set hiddenIds, +) { + final albumGroups = {}; + for (final h in historyItems) { + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; + final existing = albumGroups[albumKey]; + if (existing == null) { + albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); + } else { + existing.count++; + if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { + existing.mostRecent = h; + } + } + } + + final downloadIds = []; + final visibleDownloads = []; + final downloadFilePathByRecentKey = {}; + for (final aggregate in albumGroups.values) { + final mostRecent = aggregate.mostRecent; + final artistForKey = + (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + final isSingleTrack = aggregate.count == 1; + final recentId = isSingleTrack + ? (mostRecent.spotifyId ?? mostRecent.id) + : '${mostRecent.albumName}|$artistForKey'; + final recent = RecentAccessItem( + id: recentId, + name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, + subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, + imageUrl: mostRecent.coverUrl, + type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ); + + downloadIds.add(recentId); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; + if (!hiddenIds.contains(recentId)) { + visibleDownloads.add(recent); + } + } + + visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + if (visibleDownloads.length > 10) { + visibleDownloads.removeRange(10, visibleDownloads.length); + } + + final allItems = [...items, ...visibleDownloads]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final seen = {}; + final uniqueItems = []; + for (final item in allItems) { + final key = '${item.type.name}:${item.id}'; + if (seen.add(key)) { + uniqueItems.add(item); + if (uniqueItems.length >= 10) { + break; + } + } + } + + return _RecentAccessView( + uniqueItems: uniqueItems, + downloadIds: downloadIds, + downloadFilePathByRecentKey: downloadFilePathByRecentKey, + hasHiddenDownloads: hiddenIds.isNotEmpty, + ); +} + +final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { + final historyItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final recentAccessItems = ref.watch( + recentAccessProvider.select((s) => s.items), + ); + final hiddenDownloadIds = ref.watch( + recentAccessProvider.select((s) => s.hiddenDownloadIds), + ); + return _buildRecentAccessViewData( + recentAccessItems, + historyItems, + hiddenDownloadIds, + ); +}); + class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); @@ -79,13 +195,24 @@ class _HomeTabState extends ConsumerState static const int _minLiveSearchChars = 3; static const Duration _liveSearchDelay = Duration(milliseconds: 800); - List? _recentAccessHistoryCache; - List? _recentAccessItemsCache; - Set? _recentAccessHiddenIdsCache; - _RecentAccessView? _recentAccessViewCache; bool _embeddedCoverRefreshScheduled = false; List? _thumbnailSizesExtensionsCache; + bool _isCsvImporting = false; + + void _setCsvImporting(bool value) { + if (_isCsvImporting == value) return; + if (!mounted) { + _isCsvImporting = value; + return; + } + setState(() { + _isCsvImporting = value; + }); + } + Map? _thumbnailSizesCache; + List? _searchBucketsSourceTracks; + _SearchResultBuckets? _searchBucketsCache; double _responsiveScale({ required BuildContext context, @@ -140,6 +267,12 @@ class _HomeTabState extends ConsumerState _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + // Run an initial fetch check in case extensions were already initialized + // before HomeTab was mounted (e.g. auto-installed during first setup). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchExploreIfNeeded(); + }); + _trackStateSub = ref.listenManual(trackProvider, ( previous, next, @@ -230,6 +363,74 @@ class _HomeTabState extends ConsumerState return map; } + List _resolveSearchFilters( + String? currentSearchProvider, + List extensions, + ) { + final isUsingExtensionSearch = + currentSearchProvider != null && + currentSearchProvider.isNotEmpty && + extensions.any((e) => e.id == currentSearchProvider && e.enabled); + + if (isUsingExtensionSearch) { + final currentSearchExtension = extensions + .where((e) => e.id == currentSearchProvider && e.enabled) + .firstOrNull; + final filters = currentSearchExtension?.searchBehavior?.filters; + if (filters != null && filters.isNotEmpty) { + return filters; + } + } + + return const [ + SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), + SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), + SearchFilter(id: 'album', label: 'Albums', icon: 'album'), + SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), + ]; + } + + _SearchResultBuckets _getSearchResultBuckets(List tracks) { + final cached = _searchBucketsCache; + if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { + return cached; + } + + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(i); + } + if (track.isAlbumItem) { + albumItems.add(track); + } + if (track.isPlaylistItem) { + playlistItems.add(track); + } + if (track.isArtistItem) { + artistItems.add(track); + } + } + + final buckets = _SearchResultBuckets( + realTracks: realTracks, + realTrackIndexes: realTrackIndexes, + albumItems: albumItems, + playlistItems: playlistItems, + artistItems: artistItems, + ); + _searchBucketsSourceTracks = tracks; + _searchBucketsCache = buckets; + return buckets; + } + void _onSearchFocusChanged() { if (mounted) { setState(() {}); @@ -502,20 +703,28 @@ class _HomeTabState extends ConsumerState } Future _importCsv(BuildContext context, WidgetRef ref) async { + if (_isCsvImporting) return; + _setCsvImporting(true); + int currentProgress = 0; int totalTracks = 0; - bool dialogShown = false; + bool progressDialogInitialized = false; + bool progressDialogVisible = false; + BuildContext? progressDialogContext; StateSetter? setDialogState; void showProgressDialog() { - if (dialogShown || !mounted) return; - dialogShown = true; + if (progressDialogInitialized || !mounted) return; + progressDialogInitialized = true; + progressDialogVisible = true; showDialog( context: this.context, + useRootNavigator: false, barrierDismissible: false, builder: (dialogCtx) => StatefulBuilder( builder: (dialogCtx, setState) { + progressDialogContext = dialogCtx; setDialogState = setState; return AlertDialog( content: Column( @@ -536,169 +745,193 @@ class _HomeTabState extends ConsumerState ); }, ), - ); + ).then((_) { + progressDialogVisible = false; + progressDialogContext = null; + }); } - final tracks = await CsvImportService.pickAndParseCsv( - onProgress: (current, total) { - currentProgress = current; - totalTracks = total; - if (!dialogShown && total > 0) { - showProgressDialog(); + void closeProgressDialog() { + if (!progressDialogVisible) return; + setDialogState = null; + try { + if (progressDialogContext != null) { + Navigator.of(progressDialogContext!).pop(); + } else if (mounted) { + final navigator = Navigator.of(this.context); + if (navigator.canPop()) { + navigator.pop(); + } } - setDialogState?.call(() {}); - }, - ); - - if (dialogShown && mounted) { - Navigator.of(this.context).pop(); + } catch (_) {} + progressDialogVisible = false; + progressDialogContext = null; } - if (tracks.isNotEmpty) { - final settings = ref.read(settingsProvider); - - if (!mounted) return; - - // ignore: use_build_context_synchronously - final l10n = context.l10n; - - final options = await showDialog<_CsvImportOptions>( - context: this.context, - builder: (dialogCtx) { - var skipDownloaded = true; - return StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.dialogImportPlaylistMessage(tracks.length)), - const SizedBox(height: 12), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Skip already downloaded songs'), - value: skipDownloaded, - onChanged: (value) { - setDialogState(() { - skipDownloaded = value ?? true; - }); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop( - dialogCtx, - const _CsvImportOptions( - confirmed: false, - skipDownloaded: true, - ), - ), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop( - dialogCtx, - _CsvImportOptions( - confirmed: true, - skipDownloaded: skipDownloaded, - ), - ), - child: Text(l10n.dialogImport), - ), - ], - ), - ); + try { + final tracks = await CsvImportService.pickAndParseCsv( + onProgress: (current, total) { + currentProgress = current; + totalTracks = total; + if (!progressDialogInitialized && total > 0) { + showProgressDialog(); + } + setDialogState?.call(() {}); }, ); - if (options == null || !options.confirmed) return; + closeProgressDialog(); - var tracksToQueue = tracks; - var skippedDownloadedCount = 0; + if (tracks.isNotEmpty) { + final settings = ref.read(settingsProvider); - if (options.skipDownloaded) { - final historyState = ref.read(downloadHistoryProvider); - tracksToQueue = []; - for (final track in tracks) { - final isDownloaded = - historyState.isDownloaded(track.id) || - (track.isrc != null && - historyState.getByIsrc(track.isrc!) != null); - if (isDownloaded) { - skippedDownloadedCount++; - continue; - } - tracksToQueue.add(track); - } - } + if (!mounted) return; - if (tracksToQueue.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text( - l10n.discographySkippedDownloaded(0, skippedDownloadedCount), - ), - ), - ); - } - return; - } + // ignore: use_build_context_synchronously + final l10n = context.l10n; - final queueSnackbarMessage = skippedDownloadedCount > 0 - ? l10n.discographySkippedDownloaded( - tracksToQueue.length, - skippedDownloadedCount, - ) - : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); - - if (!mounted) return; - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - this.context, - trackName: l10n.csvImportTracks(tracksToQueue.length), - artistName: l10n.dialogImportPlaylistTitle, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - ); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () {}, - ), + final options = await showDialog<_CsvImportOptions>( + context: this.context, + useRootNavigator: false, + builder: (dialogCtx) { + var skipDownloaded = true; + return StatefulBuilder( + builder: (dialogCtx, setDialogState) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.dialogImportPlaylistMessage(tracks.length)), + const SizedBox(height: 12), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Skip already downloaded songs'), + value: skipDownloaded, + onChanged: (value) { + setDialogState(() { + skipDownloaded = value ?? true; + }); + }, + ), + ], ), - ); - } + actions: [ + TextButton( + onPressed: () => Navigator.pop( + dialogCtx, + const _CsvImportOptions( + confirmed: false, + skipDownloaded: true, + ), + ), + child: Text(l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogCtx, + _CsvImportOptions( + confirmed: true, + skipDownloaded: skipDownloaded, + ), + ), + child: Text(l10n.dialogImport), + ), + ], + ), + ); }, ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, settings.defaultService); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () {}, + + if (options == null || !options.confirmed) return; + + var tracksToQueue = tracks; + var skippedDownloadedCount = 0; + + if (options.skipDownloaded) { + final historyState = ref.read(downloadHistoryProvider); + tracksToQueue = []; + for (final track in tracks) { + final isDownloaded = + historyState.isDownloaded(track.id) || + (track.isrc != null && + historyState.getByIsrc(track.isrc!) != null); + if (isDownloaded) { + skippedDownloadedCount++; + continue; + } + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text( + l10n.discographySkippedDownloaded(0, skippedDownloadedCount), + ), ), - ), + ); + } + return; + } + + final queueSnackbarMessage = skippedDownloadedCount > 0 + ? l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedDownloadedCount, + ) + : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); + + if (!mounted) return; + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracksToQueue.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } + }, ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracksToQueue, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } } } + } finally { + closeProgressDialog(); + _setCsvImporting(false); } } @@ -706,25 +939,22 @@ class _HomeTabState extends ConsumerState Widget build(BuildContext context) { super.build(context); - final tracks = ref.watch(trackProvider.select((s) => s.tracks)); - final searchArtists = ref.watch( - trackProvider.select((s) => s.searchArtists), - ); - final searchAlbums = ref.watch(trackProvider.select((s) => s.searchAlbums)); - final searchPlaylists = ref.watch( - trackProvider.select((s) => s.searchPlaylists), + final hasActualResults = ref.watch( + trackProvider.select( + (s) => + s.tracks.isNotEmpty || + (s.searchArtists != null && s.searchArtists!.isNotEmpty) || + (s.searchAlbums != null && s.searchAlbums!.isNotEmpty) || + (s.searchPlaylists != null && s.searchPlaylists!.isNotEmpty), + ), ); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final error = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); - final exploreSections = ref.watch( - exploreProvider.select((s) => s.sections), - ); - final exploreGreeting = ref.watch( - exploreProvider.select((s) => s.greeting), + final hasExploreContent = ref.watch( + exploreProvider.select((s) => s.sections.isNotEmpty), ); final exploreLoading = ref.watch( exploreProvider.select((s) => s.isLoading), @@ -736,11 +966,6 @@ class _HomeTabState extends ConsumerState ); final colorScheme = Theme.of(context).colorScheme; - final hasActualResults = - tracks.isNotEmpty || - (searchArtists != null && searchArtists.isNotEmpty) || - (searchAlbums != null && searchAlbums.isNotEmpty) || - (searchPlaylists != null && searchPlaylists.isNotEmpty); final searchText = _urlController.text.trim(); final hasSearchInput = searchText.isNotEmpty; final isSearchFocused = _searchFocusNode.hasFocus; @@ -755,12 +980,6 @@ class _HomeTabState extends ConsumerState final historyItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); - final recentAccessItems = ref.watch( - recentAccessProvider.select((s) => s.items), - ); - final hiddenDownloadIds = ref.watch( - recentAccessProvider.select((s) => s.hiddenDownloadIds), - ); final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = @@ -769,15 +988,6 @@ class _HomeTabState extends ConsumerState !isLoading; final hasResults = hasSearchInput || hasActualResults || isLoading || showRecentAccess; - final recentAccessView = showRecentAccess - ? _getRecentAccessView( - recentAccessItems, - historyItems, - hiddenDownloadIds, - ) - : null; - - final hasExploreContent = exploreSections.isNotEmpty; final showExplore = !hasActualResults && !isLoading && @@ -785,52 +995,6 @@ class _HomeTabState extends ConsumerState (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; - // Get current search extension and its filters - final currentSearchProvider = ref.watch( - settingsProvider.select((s) => s.searchProvider), - ); - final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); - final selectedSearchFilter = ref.watch( - trackProvider.select((s) => s.selectedSearchFilter), - ); - final searchExtensionId = ref.watch( - trackProvider.select((s) => s.searchExtensionId), - ); - final localLibrarySettings = ref.watch( - settingsProvider.select( - (s) => (s.localLibraryEnabled, s.localLibraryShowDuplicates), - ), - ); - final showLocalLibraryIndicator = - localLibrarySettings.$1 && localLibrarySettings.$2; - final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId( - extensions, - ); - Extension? currentSearchExtension; - List searchFilters = []; - - final isUsingExtensionSearch = - currentSearchProvider != null && - currentSearchProvider.isNotEmpty && - extensions.any((e) => e.id == currentSearchProvider && e.enabled); - - if (isUsingExtensionSearch) { - currentSearchExtension = extensions - .where((e) => e.id == currentSearchProvider && e.enabled) - .firstOrNull; - if (currentSearchExtension?.searchBehavior?.filters.isNotEmpty == true) { - searchFilters = currentSearchExtension!.searchBehavior!.filters; - } - } else { - // Default Deezer filters - searchFilters = const [ - SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), - SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), - SearchFilter(id: 'album', label: 'Albums', icon: 'album'), - SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), - ]; - } - if (hasActualResults && isShowingRecentAccess && hasSearchInput && @@ -953,20 +1117,45 @@ class _HomeTabState extends ConsumerState ), // Search filter bar (only shown when has search results) - if (searchFilters.isNotEmpty && - hasActualResults && - !showRecentAccess) - SliverToBoxAdapter( - child: _buildSearchFilterBar( - searchFilters, - selectedSearchFilter, - colorScheme, - ), + if (hasActualResults && !showRecentAccess) + Consumer( + builder: (context, ref, _) { + final currentSearchProvider = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final selectedSearchFilter = ref.watch( + trackProvider.select((s) => s.selectedSearchFilter), + ); + final searchFilters = _resolveSearchFilters( + currentSearchProvider, + extensions, + ); + if (searchFilters.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverToBoxAdapter( + child: _buildSearchFilterBar( + searchFilters, + selectedSearchFilter, + colorScheme, + ), + ); + }, ), if (showRecentAccess) - SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessView!, colorScheme), + Consumer( + builder: (context, ref, _) { + final recentAccessView = ref.watch( + recentAccessViewProvider, + ); + return SliverToBoxAdapter( + child: _buildRecentAccess(recentAccessView, colorScheme), + ); + }, ), SliverToBoxAdapter( @@ -1008,10 +1197,22 @@ class _HomeTabState extends ConsumerState ), if (showExplore) - ..._buildExploreSections( - exploreSections, - exploreGreeting, - colorScheme, + Consumer( + builder: (context, ref, _) { + final exploreSections = ref.watch( + exploreProvider.select((s) => s.sections), + ); + final exploreGreeting = ref.watch( + exploreProvider.select((s) => s.greeting), + ); + return SliverMainAxisGroup( + slivers: _buildExploreSections( + exploreSections, + exploreGreeting, + colorScheme, + ), + ); + }, ), if (hasHomeFeedExtension && @@ -1025,18 +1226,63 @@ class _HomeTabState extends ConsumerState ), ), - ..._buildSearchResults( - tracks: tracks, - searchArtists: searchArtists, - searchAlbums: searchAlbums, - searchPlaylists: searchPlaylists, - isLoading: isLoading, - error: error, - colorScheme: colorScheme, - hasResults: hasActualResults || isLoading, - searchExtensionId: searchExtensionId, - showLocalLibraryIndicator: showLocalLibraryIndicator, - thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + Consumer( + builder: (context, ref, _) { + final tracks = ref.watch( + trackProvider.select((s) => s.tracks), + ); + final searchArtists = ref.watch( + trackProvider.select((s) => s.searchArtists), + ); + final searchAlbums = ref.watch( + trackProvider.select((s) => s.searchAlbums), + ); + final searchPlaylists = ref.watch( + trackProvider.select((s) => s.searchPlaylists), + ); + final isLoading = ref.watch( + trackProvider.select((s) => s.isLoading), + ); + final error = ref.watch(trackProvider.select((s) => s.error)); + final searchExtensionId = ref.watch( + trackProvider.select((s) => s.searchExtensionId), + ); + final localLibrarySettings = ref.watch( + settingsProvider.select( + (s) => + (s.localLibraryEnabled, s.localLibraryShowDuplicates), + ), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final showLocalLibraryIndicator = + localLibrarySettings.$1 && localLibrarySettings.$2; + final thumbnailSizesByExtensionId = + _getThumbnailSizesByExtensionId(extensions); + final hasResults = + tracks.isNotEmpty || + (searchArtists != null && searchArtists.isNotEmpty) || + (searchAlbums != null && searchAlbums.isNotEmpty) || + (searchPlaylists != null && searchPlaylists.isNotEmpty) || + isLoading; + + return SliverMainAxisGroup( + slivers: _buildSearchResults( + tracks: tracks, + searchArtists: searchArtists, + searchAlbums: searchAlbums, + searchPlaylists: searchPlaylists, + isLoading: isLoading, + error: error, + colorScheme: colorScheme, + hasResults: hasResults, + searchExtensionId: searchExtensionId, + showLocalLibraryIndicator: showLocalLibraryIndicator, + thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + ), + ); + }, ), ], ), @@ -1159,103 +1405,6 @@ class _HomeTabState extends ConsumerState ); } - _RecentAccessView _getRecentAccessView( - List items, - List historyItems, - Set hiddenIds, - ) { - final cached = _recentAccessViewCache; - if (cached != null && - identical(historyItems, _recentAccessHistoryCache) && - identical(items, _recentAccessItemsCache) && - identical(hiddenIds, _recentAccessHiddenIdsCache)) { - return cached; - } - - final albumGroups = {}; - for (final h in historyItems) { - final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) - ? h.albumArtist! - : h.artistName; - final albumKey = '${h.albumName}|$artistForKey'; - final existing = albumGroups[albumKey]; - if (existing == null) { - albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); - } else { - existing.count++; - if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { - existing.mostRecent = h; - } - } - } - - final downloadIds = []; - final visibleDownloads = []; - final downloadFilePathByRecentKey = {}; - for (final aggregate in albumGroups.values) { - final mostRecent = aggregate.mostRecent; - final artistForKey = - (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) - ? mostRecent.albumArtist! - : mostRecent.artistName; - - final isSingleTrack = aggregate.count == 1; - final recentId = isSingleTrack - ? (mostRecent.spotifyId ?? mostRecent.id) - : '${mostRecent.albumName}|$artistForKey'; - final recent = RecentAccessItem( - id: recentId, - name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, - subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, - imageUrl: mostRecent.coverUrl, - type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ); - - downloadIds.add(recentId); - downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = - mostRecent.filePath; - if (!hiddenIds.contains(recentId)) { - visibleDownloads.add(recent); - } - } - - visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - if (visibleDownloads.length > 10) { - visibleDownloads.removeRange(10, visibleDownloads.length); - } - - final allItems = [...items, ...visibleDownloads]; - allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final seen = {}; - final uniqueItems = []; - for (final item in allItems) { - final key = '${item.type.name}:${item.id}'; - if (seen.add(key)) { - uniqueItems.add(item); - if (uniqueItems.length >= 10) { - break; - } - } - } - - final view = _RecentAccessView( - uniqueItems: uniqueItems, - downloadIds: downloadIds, - downloadFilePathByRecentKey: downloadFilePathByRecentKey, - hasHiddenDownloads: hiddenIds.isNotEmpty, - ); - - _recentAccessHistoryCache = historyItems; - _recentAccessItemsCache = items; - _recentAccessHiddenIdsCache = hiddenIds; - _recentAccessViewCache = view; - - return view; - } - List _buildExploreSections( List sections, String? greeting, @@ -1407,8 +1556,10 @@ class _HomeTabState extends ConsumerState ), ), if (item.artists.isNotEmpty && !isArtist) - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -1499,6 +1650,7 @@ class _HomeTabState extends ConsumerState showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), @@ -1554,8 +1706,10 @@ class _HomeTabState extends ConsumerState overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, @@ -1573,7 +1727,7 @@ class _HomeTabState extends ConsumerState title: Text(context.l10n.downloadTitle), onTap: () { Navigator.pop(context); - _downloadExploreTrack(item); + _handleExploreTrackPrimaryAction(item); }, ), ListTile( @@ -1591,7 +1745,7 @@ class _HomeTabState extends ConsumerState ); } - Future _downloadExploreTrack(ExploreItem item) async { + Future _handleExploreTrackPrimaryAction(ExploreItem item) async { final settings = ref.read(settingsProvider); final track = Track( @@ -1599,6 +1753,7 @@ class _HomeTabState extends ConsumerState name: item.name, artistName: item.artists, albumName: item.albumName ?? '', + albumId: item.albumId, duration: item.durationMs ~/ 1000, trackNumber: 1, discNumber: 1, @@ -1921,6 +2076,7 @@ class _HomeTabState extends ConsumerState ), ); } + return; case RecentAccessType.album: if (item.providerId == 'download') { Navigator.push( @@ -1960,6 +2116,7 @@ class _HomeTabState extends ConsumerState ), ); } + return; case RecentAccessType.track: final historyItem = ref .read(downloadHistoryProvider.notifier) @@ -1971,10 +2128,44 @@ class _HomeTabState extends ConsumerState context, ).showSnackBar(SnackBar(content: Text(item.name))); } + return; case RecentAccessType.playlist: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), - ); + if (item.id.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), + ); + return; + } + + if (item.providerId != null && + item.providerId!.isNotEmpty && + item.providerId != 'deezer' && + item.providerId != 'spotify') { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: item.providerId!, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistScreen( + playlistName: item.name, + coverUrl: item.imageUrl, + tracks: const [], + playlistId: item.id, + ), + ), + ); + } + return; } } @@ -2107,28 +2298,12 @@ class _HomeTabState extends ConsumerState return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - final realTracks = []; - final realTrackIndexes = []; - final albumItems = []; - final playlistItems = []; - final artistItems = []; - - for (int i = 0; i < tracks.length; i++) { - final track = tracks[i]; - if (!track.isCollection) { - realTracks.add(track); - realTrackIndexes.add(i); - } - if (track.isAlbumItem) { - albumItems.add(track); - } - if (track.isPlaylistItem) { - playlistItems.add(track); - } - if (track.isArtistItem) { - artistItems.add(track); - } - } + final buckets = _getSearchResultBuckets(tracks); + final realTracks = buckets.realTracks; + final realTrackIndexes = buckets.realTrackIndexes; + final albumItems = buckets.albumItems; + final playlistItems = buckets.playlistItems; + final artistItems = buckets.artistItems; final slivers = [ if (error != null) @@ -2676,7 +2851,9 @@ class _HomeTabState extends ConsumerState else ...[ IconButton( icon: const Icon(Icons.file_upload_outlined), - onPressed: () => _importCsv(context, ref), + onPressed: _isCsvImporting + ? null + : () => _importCsv(context, ref), tooltip: 'Import CSV', ), IconButton( @@ -2969,6 +3146,11 @@ class _TrackItemWithStatus extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( @@ -3014,8 +3196,11 @@ class _TrackItemWithStatus extends ConsumerWidget { Row( children: [ Flexible( - child: Text( - track.artistName, + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + extensionId: extensionId, style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -3061,9 +3246,7 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), - TrackCollectionQuickActions( - track: track, - ), + TrackCollectionQuickActions(track: track), ], ), ), @@ -3407,8 +3590,11 @@ class _SearchAlbumItemWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - album.artists.isNotEmpty ? album.artists : 'Album', + ClickableArtistName( + artistName: album.artists.isNotEmpty + ? album.artists + : 'Album', + coverUrl: album.imageUrl, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3608,7 +3794,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { .toList(); // Extract artist info from album response - final artistId = result['artist_id'] as String?; + final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistName = result['artists'] as String?; setState(() { @@ -3640,6 +3826,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? widget.albumName).toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, coverUrl: _resolveCoverUrl( data['cover_url']?.toString(), widget.coverUrl, @@ -3792,6 +3981,8 @@ class _ExtensionPlaylistScreenState name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? '').toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: _resolveCoverUrl( data['cover_url']?.toString(), widget.coverUrl, @@ -3963,6 +4154,10 @@ class _ExtensionArtistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + widget.artistId, + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -4177,8 +4372,10 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), if (item.artists.isNotEmpty) - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index 5a2eaf7f..2f07b435 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -149,6 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -264,6 +265,9 @@ class LibraryPlaylistsScreen extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; const double size = 48; final borderRadius = BorderRadius.circular(8); + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (size * dpr).round().clamp(64, 512); + final placeholder = _playlistIconFallback(colorScheme, size); // Priority: custom cover > first track cover URL > icon fallback final customCoverPath = playlist.coverImagePath; @@ -275,7 +279,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -302,7 +313,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -314,15 +332,15 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - memCacheWidth: (size * 2).toInt(), + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => _playlistIconFallback(colorScheme, size), - errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, ), ); } - return _playlistIconFallback(colorScheme, size); + return placeholder; } Widget _playlistIconFallback(ColorScheme colorScheme, double size) { diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index cd9e68c4..4198be85 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -75,11 +77,47 @@ class _LibraryTracksFolderScreenState }; } + String? _resolveEntryCoverUrl( + CollectionTrackEntry entry, + LocalLibraryState localState, + ) { + final rawCover = entry.track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + + final isrc = entry.track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localState.getByIsrc(isrc); + final localCover = byIsrc?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) { + return localCover; + } + } + + final byTrack = localState.findByTrackAndArtist( + entry.track.name, + entry.track.artistName, + ); + final localCover = byTrack?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) { + return localCover; + } + + return null; + } + /// Find the first available cover URL from entries. - String? _firstCoverUrl(List entries) { + String? _firstCoverUrl( + List entries, + LocalLibraryState localState, + ) { for (final entry in entries) { - if (entry.track.coverUrl != null && entry.track.coverUrl!.isNotEmpty) { - return entry.track.coverUrl; + final cover = _resolveEntryCoverUrl(entry, localState); + if (cover != null && cover.isNotEmpty) { + return cover; } } return null; @@ -173,11 +211,7 @@ class _LibraryTracksFolderScreenState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionSelected(count), - ), - ), + SnackBar(content: Text(context.l10n.selectionSelected(count))), ); } @@ -196,11 +230,7 @@ class _LibraryTracksFolderScreenState if (!mounted || count == 0) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionSelected(count), - ), - ), + SnackBar(content: Text(context.l10n.selectionSelected(count))), ); } @@ -217,6 +247,8 @@ class _LibraryTracksFolderScreenState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + ref.watch(localLibraryProvider.select((s) => s.items)); + final localState = ref.read(localLibraryProvider); final UserPlaylistCollection? playlist; final List entries; @@ -280,6 +312,9 @@ class _LibraryTracksFolderScreenState LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylistEmptySubtitle, }; + final folderTracks = entries + .map((entry) => entry.track) + .toList(growable: false); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -296,7 +331,14 @@ class _LibraryTracksFolderScreenState CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar(context, colorScheme, title, entries, playlist), + _buildAppBar( + context, + colorScheme, + title, + entries, + playlist, + localState, + ), if (entries.isEmpty) SliverFillRemaining( hasScrollBody: false, @@ -316,6 +358,8 @@ class _LibraryTracksFolderScreenState entry: entry, mode: widget.mode, playlistId: widget.playlistId, + localLibraryState: localState, + folderTracks: folderTracks, isSelectionMode: _isSelectionMode, isSelected: isSelected, onTap: _isSelectionMode @@ -494,8 +538,8 @@ class _LibraryTracksFolderScreenState selectedCount > 0 ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' : widget.mode == LibraryTracksFolderMode.playlist - ? context.l10n.collectionRemoveFromPlaylist - : context.l10n.collectionRemoveFromFolder, + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 @@ -551,13 +595,14 @@ class _LibraryTracksFolderScreenState String title, List entries, UserPlaylistCollection? playlist, + LocalLibraryState localState, ) { final expandedHeight = _calculateExpandedHeight(context); final customCoverPath = playlist?.coverImagePath; final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; // Loved always shows the heart icon (like Spotify's Liked Songs) - final coverUrl = isLovedMode ? null : _firstCoverUrl(entries); + final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState); final hasCustomCover = customCoverPath != null && customCoverPath.isNotEmpty; final hasCoverUrl = coverUrl != null; @@ -608,6 +653,18 @@ class _LibraryTracksFolderScreenState (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (MediaQuery.sizeOf(context).width * dpr) + .round() + .clamp(320, 2048); + final coverFallback = Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -619,26 +676,37 @@ class _LibraryTracksFolderScreenState Image.file( File(customCoverPath), fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - _modeIcon(), - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return coverFallback; + }, + errorBuilder: (_, _, _) => coverFallback, ) else if (hasCoverUrl) _isCoverLocalPath(coverUrl) ? Image.file( File(coverUrl), fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: + (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(color: colorScheme.surface); + }, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) : CachedNetworkImage( imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -646,14 +714,7 @@ class _LibraryTracksFolderScreenState Container(color: colorScheme.surface), ) else - Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - _modeIcon(), - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), + coverFallback, // Bottom gradient for readability Positioned( left: 0, @@ -728,6 +789,18 @@ class _LibraryTracksFolderScreenState ], ), ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.mode != + LibraryTracksFolderMode.wishlist) ...[ + _buildShuffleButton(entries), + const SizedBox(width: 12), + ], + _buildDownloadAllCenterButton(context, entries), + ], + ), ], ], ), @@ -758,11 +831,127 @@ class _LibraryTracksFolderScreenState ); } + // ── Shuffle / Download buttons ── + + Widget _buildShuffleButton(List entries) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: entries.isEmpty ? null : () => _shufflePlay(entries), + icon: const Icon(Icons.shuffle_rounded, size: 22, color: Colors.white), + tooltip: 'Shuffle Play', + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildDownloadAllCenterButton( + BuildContext context, + List entries, + ) { + final tracks = entries.map((e) => e.track).toList(growable: false); + return FilledButton.icon( + onPressed: tracks.isEmpty ? null : () => _downloadAll(context, tracks), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(tracks.length)), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + void _shufflePlay(List entries) { + final tracks = entries.map((e) => e.track).toList(growable: false); + if (tracks.isEmpty) return; + final shuffled = [...tracks]..shuffle(); + final messenger = ScaffoldMessenger.of(context); + ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Cannot shuffle play local tracks: $e')), + ); + }); + } + + void _downloadAll(BuildContext context, List tracks) { + if (tracks.isEmpty) return; + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: const Text('Download All'), + content: Text('Download ${tracks.length} tracks?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _executeDownloadAll(context, tracks); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); + } + + void _executeDownloadAll(BuildContext context, List tracks) { + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: '', + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, service, qualityOverride: quality); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(tracks.length), + ), + ), + ); + }, + ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + ), + ); + } + } + void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -840,6 +1029,8 @@ class _CollectionTrackTile extends ConsumerWidget { final CollectionTrackEntry entry; final LibraryTracksFolderMode mode; final String? playlistId; + final LocalLibraryState localLibraryState; + final List folderTracks; final bool isSelectionMode; final bool isSelected; final VoidCallback? onTap; @@ -849,6 +1040,8 @@ class _CollectionTrackTile extends ConsumerWidget { required this.entry, required this.mode, required this.playlistId, + required this.localLibraryState, + required this.folderTracks, this.isSelectionMode = false, this.isSelected = false, this.onTap, @@ -859,6 +1052,7 @@ class _CollectionTrackTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; + final effectiveCoverUrl = _resolveCoverUrl(track); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -903,8 +1097,8 @@ class _CollectionTrackTile extends ConsumerWidget { ], ClipRRect( borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && track.coverUrl!.isNotEmpty - ? _buildTrackCover(context, track.coverUrl!, 52) + child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) : Container( width: 52, height: 52, @@ -917,8 +1111,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), ], ), - title: - Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text( track.artistName, maxLines: 1, @@ -936,15 +1129,45 @@ class _CollectionTrackTile extends ConsumerWidget { ), onTap: isSelectionMode ? onTap - : mode == LibraryTracksFolderMode.wishlist - ? () => _downloadTrack(context, ref) - : () => _navigateToMetadata(context, ref), + : () { + if (mode == LibraryTracksFolderMode.wishlist) { + _downloadTrack(context, ref); + return; + } + + _navigateToMetadata(context, ref); + }, onLongPress: isSelectionMode ? onTap : onLongPress, ), ), ); } + String? _resolveCoverUrl(Track track) { + final rawCover = track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localLibraryState.getByIsrc(isrc); + final localCover = byIsrc?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) return localCover; + } + + final byTrack = localLibraryState.findByTrackAndArtist( + track.name, + track.artistName, + ); + final localCover = byTrack?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) return localCover; + + return null; + } + /// Builds a cover image widget that handles both network URLs and local file paths. Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { final isLocal = @@ -984,9 +1207,11 @@ class _CollectionTrackTile extends ConsumerWidget { void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { final track = entry.track; + final effectiveCoverUrl = _resolveCoverUrl(track); final colorScheme = Theme.of(context).colorScheme; final historyState = ref.read(downloadHistoryProvider); - final isDownloaded = historyState.isDownloaded(track.id) || + final isDownloaded = + historyState.isDownloaded(track.id) || (track.isrc != null && track.isrc!.isNotEmpty && historyState.getByIsrc(track.isrc!) != null) || @@ -997,6 +1222,7 @@ class _CollectionTrackTile extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1024,8 +1250,9 @@ class _CollectionTrackTile extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(8), child: - track.coverUrl != null && track.coverUrl!.isNotEmpty - ? _buildTrackCover(context, track.coverUrl!, 56) + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 56) : Container( width: 56, height: 56, @@ -1170,15 +1397,15 @@ class _CollectionTrackTile extends ConsumerWidget { var historyItem = historyState.getBySpotifyId(track.id); // 2. Download history by ISRC - if (historyItem == null && - track.isrc != null && - track.isrc!.isNotEmpty) { + if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) { historyItem = historyState.getByIsrc(track.isrc!); } // 3. Download history by track name + artist (handles ID/ISRC mismatch) - historyItem ??= - historyState.findByTrackAndArtist(track.name, track.artistName); + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); if (historyItem != null) { await Navigator.of(context).push( @@ -1287,9 +1514,7 @@ class _SelectionActionButton extends StatelessWidget { ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e7642a45..83298d72 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; /// Screen to display tracks from a local library album class LocalAlbumScreen extends ConsumerStatefulWidget { @@ -204,9 +205,17 @@ class _LocalAlbumScreenState extends ConsumerState { } } - Future _openFile(String filePath) async { + Future _openFile(LocalLibraryItem track) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: track.filePath, + title: track.trackName, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverPath ?? '', + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -639,7 +648,7 @@ class _LocalAlbumScreenState extends ConsumerState { ), onTap: _isSelectionMode ? () => _toggleSelection(track.id) - : () => _openFile(track.filePath), + : () => _openFile(track), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), @@ -724,7 +733,7 @@ class _LocalAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - onPressed: () => _openFile(track.filePath), + onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( @@ -989,6 +998,7 @@ class _LocalAlbumScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 8d7b96ca..42e20dd2 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -16,9 +16,11 @@ import 'package:spotiflac_android/screens/store_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; +import 'package:spotiflac_android/widgets/mini_player_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -36,11 +38,21 @@ class _MainShellState extends ConsumerState { bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; + final GlobalKey _homeTabNavigatorKey = + ShellNavigationService.homeTabNavigatorKey; + final GlobalKey _libraryTabNavigatorKey = + ShellNavigationService.libraryTabNavigatorKey; + final GlobalKey _storeTabNavigatorKey = + ShellNavigationService.storeTabNavigatorKey; @override void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: false, + ); WidgetsBinding.instance.addPostFrameCallback((_) { _checkForUpdates(); _setupShareListener(); @@ -86,6 +98,7 @@ class _MainShellState extends ConsumerState { if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); + _homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); if (_currentIndex != 0) { _onNavTap(0); @@ -213,10 +226,34 @@ class _MainShellState extends ConsumerState { super.dispose(); } + void _resetHomeToMain() { + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + final homeNavigator = _navigatorForTab(0, showStore); + homeNavigator?.popUntil((route) => route.isFirst); + // Unfocus BEFORE clear so _onTrackStateChanged can properly + // clear _urlController (it checks !_searchFocusNode.hasFocus) + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(trackProvider.notifier).clear(); + } + void _onNavTap(int index) { + if (index == 0 && _currentIndex == 0) { + _resetHomeToMain(); + return; + } + if (_currentIndex != index) { HapticFeedback.selectionClick(); setState(() => _currentIndex = index); + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); _pageController.animateToPage( index, duration: const Duration(milliseconds: 250), @@ -226,48 +263,121 @@ class _MainShellState extends ConsumerState { } void _onPageChanged(int index) { + final previousIndex = _currentIndex; if (_currentIndex != index) { setState(() => _currentIndex = index); + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); FocusManager.instance.primaryFocus?.unfocus(); + if (index == 0 && previousIndex != 0) { + _resetHomeToMain(); + } } } void _handleBackPress() { + final rootNavigator = Navigator.of(context, rootNavigator: true); + if (rootNavigator.canPop()) { + _log.i('Back: step 1 - root navigator pop'); + rootNavigator.pop(); + _lastBackPress = null; + return; + } + + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + final currentNavigator = _navigatorForTab(_currentIndex, showStore); + if (currentNavigator != null && currentNavigator.canPop()) { + _log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)'); + currentNavigator.pop(); + _lastBackPress = null; + return; + } + final trackState = ref.read(trackProvider); final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - if (isKeyboardVisible) { + + _log.d( + 'Back: state check - tab=$_currentIndex, ' + 'isShowingRecentAccess=${trackState.isShowingRecentAccess}, ' + 'hasSearchText=${trackState.hasSearchText}, ' + 'hasContent=${trackState.hasContent}, ' + 'isLoading=${trackState.isLoading}, ' + 'isKeyboardVisible=$isKeyboardVisible', + ); + + if (_currentIndex == 0 && + trackState.isShowingRecentAccess && + !trackState.isLoading && + (trackState.hasSearchText || trackState.hasContent)) { + // Has recent access AND search content — clear everything at once + _log.i( + 'Back: step 3a - dismiss recent access + clear search/content ' + '(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})', + ); FocusManager.instance.primaryFocus?.unfocus(); + ref.read(trackProvider.notifier).clear(); + _lastBackPress = null; return; } if (_currentIndex == 0 && trackState.isShowingRecentAccess) { + // Recent access overlay only (no search content) — just dismiss it + _log.i('Back: step 3b - dismiss recent access only'); ref.read(trackProvider.notifier).setShowingRecentAccess(false); FocusManager.instance.primaryFocus?.unfocus(); + _lastBackPress = null; return; } if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { + _log.i( + 'Back: step 4 - clear search/content ' + '(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})', + ); + // Unfocus BEFORE clear so _onTrackStateChanged can properly + // clear _urlController (it checks !_searchFocusNode.hasFocus) + FocusManager.instance.primaryFocus?.unfocus(); ref.read(trackProvider.notifier).clear(); + _lastBackPress = null; + return; + } + + if (_currentIndex == 0 && isKeyboardVisible) { + _log.i('Back: step 5 - dismiss keyboard'); + FocusManager.instance.primaryFocus?.unfocus(); + _lastBackPress = null; return; } if (_currentIndex != 0) { + _log.i('Back: step 6 - switch to home tab from tab=$_currentIndex'); _onNavTap(0); + _lastBackPress = null; return; } if (trackState.isLoading) { + _log.i('Back: blocked - loading in progress'); return; } final now = DateTime.now(); if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { + _log.i('Back: step 8 - double-tap exit'); SystemNavigator.pop(); } else { + _log.i('Back: step 7 - first tap, showing exit snackbar'); _lastBackPress = now; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -279,46 +389,46 @@ class _MainShellState extends ConsumerState { } } + NavigatorState? _navigatorForTab(int index, bool showStore) { + if (index == 0) return _homeTabNavigatorKey.currentState; + if (index == 1) return _libraryTabNavigatorKey.currentState; + if (showStore && index == 2) return _storeTabNavigatorKey.currentState; + return null; + } + @override Widget build(BuildContext context) { final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); - final trackHasSearchText = ref.watch( - trackProvider.select((s) => s.hasSearchText), - ); - final trackHasContent = ref.watch( - trackProvider.select((s) => s.hasContent), - ); - final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final trackIsShowingRecentAccess = ref.watch( - trackProvider.select((s) => s.isShowingRecentAccess), - ); final showStore = ref.watch( settingsProvider.select((s) => s.showExtensionStore), ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); final storeUpdatesCount = ref.watch( storeProvider.select((s) => s.updatesAvailableCount), ); - final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - - final canPop = - _currentIndex == 0 && - !trackHasSearchText && - !trackHasContent && - !trackIsLoading && - !trackIsShowingRecentAccess && - !isKeyboardVisible; - final tabs = [ - const HomeTab(), - QueueTab( - parentPageController: _pageController, - parentPageIndex: 1, - nextPageIndex: showStore ? 2 : 3, + _TabNavigator( + key: const ValueKey('tab-home'), + navigatorKey: _homeTabNavigatorKey, + child: const HomeTab(), ), - if (showStore) const StoreTab(), + _TabNavigator( + key: const ValueKey('tab-library'), + navigatorKey: _libraryTabNavigatorKey, + child: _LibraryTabRoot(parentPageController: _pageController), + ), + if (showStore) + _TabNavigator( + key: const ValueKey('tab-store'), + navigatorKey: _storeTabNavigatorKey, + child: const StoreTab(), + ), const SettingsTab(), ]; @@ -378,7 +488,7 @@ class _MainShellState extends ConsumerState { } return PopScope( - canPop: canPop, + canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) { return; @@ -387,13 +497,18 @@ class _MainShellState extends ConsumerState { _handleBackPress(); }, child: Scaffold( - body: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, - physics: (_currentIndex == 0 && trackIsShowingRecentAccess) - ? const _NoSwipeRightPhysics() - : const ClampingScrollPhysics(), - children: tabs, + body: Column( + children: [ + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + children: tabs, + ), + ), + const MiniPlayerBar(), + ], ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), @@ -415,23 +530,42 @@ class _MainShellState extends ConsumerState { } } -/// Custom physics that blocks swiping to the right (next page) while -/// still allowing vertical scrolling inside the page content. -class _NoSwipeRightPhysics extends ScrollPhysics { - const _NoSwipeRightPhysics({super.parent}); +class _TabNavigator extends StatelessWidget { + final GlobalKey navigatorKey; + final Widget child; + + const _TabNavigator({ + super.key, + required this.navigatorKey, + required this.child, + }); @override - _NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) { - return _NoSwipeRightPhysics(parent: buildParent(ancestor)); + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onGenerateInitialRoutes: (_, _) => [ + MaterialPageRoute(builder: (_) => child), + ], + ); } +} + +class _LibraryTabRoot extends ConsumerWidget { + final PageController parentPageController; + + const _LibraryTabRoot({required this.parentPageController}); @override - double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { - // In a horizontal PageView, a negative offset means the user is - // dragging left (i.e. trying to go to the next page / right). - // Block that direction only. - if (offset < 0) return 0.0; - return super.applyPhysicsToUserOffset(position, offset); + Widget build(BuildContext context, WidgetRef ref) { + final showStore = ref.watch( + settingsProvider.select((s) => s.showExtensionStore), + ); + return QueueTab( + parentPageController: parentPageController, + parentPageIndex: 1, + nextPageIndex: showStore ? 2 : 3, + ); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index f127d9d7..ac9a3f76 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -6,9 +6,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -108,6 +110,8 @@ class _PlaylistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -297,22 +301,15 @@ class _PlaylistScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - Center( - child: FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(_tracks.length), - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(context), + const SizedBox(width: 12), + _buildShufflePlayButton(), + ], ), ], ], @@ -410,6 +407,7 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -437,22 +435,175 @@ class _PlaylistScreenState extends ConsumerState { } } - void _downloadAll(BuildContext context) { + // ── Shuffle / Love / Download buttons ── + + Widget _buildCircleButton({ + required IconData icon, + required String tooltip, + required VoidCallback? onPressed, + }) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: onPressed, + icon: Icon(icon, size: 22, color: Colors.white), + tooltip: tooltip, + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildLoveAllButton() { + final collectionsState = ref.watch(libraryCollectionsProvider); + final allLoved = + _tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t)); + + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks), + icon: Icon( + allLoved ? Icons.favorite : Icons.favorite_border, + size: 22, + color: allLoved ? Colors.redAccent : Colors.white, + ), + tooltip: allLoved ? 'Remove from Loved' : 'Love All', + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildDownloadAllCenterButton(BuildContext context) { + return FilledButton.icon( + onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(_tracks.length)), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + Widget _buildShufflePlayButton() { + return _buildCircleButton( + icon: Icons.shuffle_rounded, + tooltip: 'Shuffle Play', + onPressed: _tracks.isEmpty ? null : _shufflePlayLocal, + ); + } + + void _shufflePlayLocal() { if (_tracks.isEmpty) return; + final shuffled = [..._tracks]..shuffle(); + final messenger = ScaffoldMessenger.of(context); + ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Cannot shuffle play local tracks: $e')), + ); + }); + } + + void _confirmDownloadAll(BuildContext context) { + if (_tracks.isEmpty) return; + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: const Text('Download All'), + content: Text('Download ${_tracks.length} tracks?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _downloadAll(context); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); + } + + Future _loveAll(List tracks) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + final allLoved = tracks.every((t) => state.isLoved(t)); + + if (allLoved) { + for (final track in tracks) { + final key = trackCollectionKey(track); + await notifier.removeFromLoved(key); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), + ); + } + } else { + int addedCount = 0; + for (final track in tracks) { + if (!state.isLoved(track)) { + await notifier.toggleLoved(track); + addedCount++; + } + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added $addedCount tracks to Loved')), + ); + } + } + } + + void _downloadAll(BuildContext context) { + _downloadTracks(context, _tracks); + } + + void _downloadTracks(BuildContext context, List tracks) { + if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${_tracks.length} tracks', + trackName: '${tracks.length} tracks', artistName: widget.playlistName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(_tracks, service, qualityOverride: quality); + .addMultipleToQueue(tracks, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarAddedTracksToQueue(_tracks.length), + context.l10n.snackbarAddedTracksToQueue(tracks.length), ), ), ); @@ -461,12 +612,10 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(_tracks, settings.defaultService); + .addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(_tracks.length), - ), + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), ), ); } @@ -602,9 +751,7 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ], ), - trailing: TrackCollectionQuickActions( - track: track, - ), + trailing: TrackCollectionQuickActions(track: track), onTap: () => _handleTap( context, ref, @@ -612,6 +759,11 @@ class _PlaylistTrackItem extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), ), ), ); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 02f05e3f..ad462f37 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -27,6 +28,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; enum LibraryItemSource { downloaded, local } @@ -363,9 +365,15 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + OverlayEntry? _selectionOverlayEntry; + List _selectionOverlayItems = const []; + double _selectionOverlayBottomPadding = 0; bool _isPlaylistSelectionMode = false; final Set _selectedPlaylistIds = {}; + OverlayEntry? _playlistSelectionOverlayEntry; + List _playlistSelectionOverlayItems = const []; + double _playlistSelectionOverlayBottomPadding = 0; PageController? _filterPageController; final List _filterModes = ['all', 'albums', 'singles']; @@ -405,6 +413,24 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; + final Map _filterContentDataCache = {}; + List? _filterCacheAllHistoryItems; + _HistoryStats? _filterCacheHistoryStats; + List? _filterCacheLocalLibraryItems; + LibraryCollectionsState? _filterCacheCollectionState; + String _filterCacheSearchQuery = ''; + String? _filterCacheSource; + String? _filterCacheQuality; + String? _filterCacheFormat; + String _filterCacheSortMode = 'latest'; + _HistoryStats? _groupedAlbumFilterHistoryStatsCache; + String _groupedAlbumFilterSearchQuery = ''; + String? _groupedAlbumFilterSource; + String? _groupedAlbumFilterQuality; + String? _groupedAlbumFilterFormat; + String _groupedAlbumFilterSortMode = 'latest'; + List<_GroupedAlbum> _filteredGroupedAlbumsCache = const []; + List<_GroupedLocalAlbum> _filteredGroupedLocalAlbumsCache = const []; // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' @@ -440,6 +466,8 @@ class _QueueTabState extends ConsumerState { @override void dispose() { + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); for (final notifier in _fileExistsNotifiers.values) { notifier.dispose(); } @@ -469,6 +497,47 @@ class _QueueTabState extends ConsumerState { _requestFilterRefresh(); } + void _invalidateFilterContentCache() { + _filterContentDataCache.clear(); + _filterCacheAllHistoryItems = null; + _filterCacheHistoryStats = null; + _filterCacheLocalLibraryItems = null; + _filterCacheCollectionState = null; + } + + void _prepareFilterContentCache({ + required List allHistoryItems, + required _HistoryStats historyStats, + required List localLibraryItems, + required LibraryCollectionsState collectionState, + }) { + final isCacheValid = + identical(_filterCacheAllHistoryItems, allHistoryItems) && + identical(_filterCacheHistoryStats, historyStats) && + identical(_filterCacheLocalLibraryItems, localLibraryItems) && + identical(_filterCacheCollectionState, collectionState) && + _filterCacheSearchQuery == _searchQuery && + _filterCacheSource == _filterSource && + _filterCacheQuality == _filterQuality && + _filterCacheFormat == _filterFormat && + _filterCacheSortMode == _sortMode; + + if (isCacheValid) { + return; + } + + _filterContentDataCache.clear(); + _filterCacheAllHistoryItems = allHistoryItems; + _filterCacheHistoryStats = historyStats; + _filterCacheLocalLibraryItems = localLibraryItems; + _filterCacheCollectionState = collectionState; + _filterCacheSearchQuery = _searchQuery; + _filterCacheSource = _filterSource; + _filterCacheQuality = _filterQuality; + _filterCacheFormat = _filterFormat; + _filterCacheSortMode = _sortMode; + } + void _ensureHistoryCaches( List items, List localItems, @@ -491,6 +560,7 @@ class _QueueTabState extends ConsumerState { _filteredLocalItemsCache = const []; } _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); if (historyChanged) { final validPaths = items @@ -736,9 +806,12 @@ class _QueueTabState extends ConsumerState { void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { + _isPlaylistSelectionMode = false; + _selectedPlaylistIds.clear(); _isSelectionMode = true; _selectedIds.add(itemId); }); + _hidePlaylistSelectionOverlay(); } void _exitSelectionMode() { @@ -746,6 +819,7 @@ class _QueueTabState extends ConsumerState { _isSelectionMode = false; _selectedIds.clear(); }); + _hideSelectionOverlay(); } void _toggleSelection(String itemId) { @@ -767,14 +841,109 @@ class _QueueTabState extends ConsumerState { }); } + void _hideSelectionOverlay() { + _selectionOverlayEntry?.remove(); + _selectionOverlayEntry = null; + } + + void _syncSelectionOverlay({ + required List items, + required double bottomPadding, + }) { + if (!mounted) return; + if (!_isSelectionMode || _isPlaylistSelectionMode) { + _hideSelectionOverlay(); + return; + } + + _selectionOverlayItems = items; + _selectionOverlayBottomPadding = bottomPadding; + + if (_selectionOverlayEntry != null) { + _selectionOverlayEntry!.markNeedsBuild(); + return; + } + + final overlay = Overlay.of(context, rootOverlay: true); + _selectionOverlayEntry = OverlayEntry( + builder: (overlayContext) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: _buildSelectionBottomBar( + context, + colorScheme, + _selectionOverlayItems, + _selectionOverlayBottomPadding, + ), + ), + ); + }, + ); + overlay.insert(_selectionOverlayEntry!); + } + + void _hidePlaylistSelectionOverlay() { + _playlistSelectionOverlayEntry?.remove(); + _playlistSelectionOverlayEntry = null; + } + + void _syncPlaylistSelectionOverlay({ + required List playlists, + required double bottomPadding, + }) { + if (!mounted) return; + if (!_isPlaylistSelectionMode || _isSelectionMode) { + _hidePlaylistSelectionOverlay(); + return; + } + + _playlistSelectionOverlayItems = playlists; + _playlistSelectionOverlayBottomPadding = bottomPadding; + + if (_playlistSelectionOverlayEntry != null) { + _playlistSelectionOverlayEntry!.markNeedsBuild(); + return; + } + + final overlay = Overlay.of(context, rootOverlay: true); + _playlistSelectionOverlayEntry = OverlayEntry( + builder: (overlayContext) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: _buildPlaylistSelectionBottomBar( + context, + colorScheme, + _playlistSelectionOverlayItems, + _playlistSelectionOverlayBottomPadding, + ), + ), + ); + }, + ); + overlay.insert(_playlistSelectionOverlayEntry!); + } + // --- Playlist selection mode --- void _enterPlaylistSelectionMode(String playlistId) { HapticFeedback.mediumImpact(); setState(() { + _isSelectionMode = false; + _selectedIds.clear(); _isPlaylistSelectionMode = true; _selectedPlaylistIds.add(playlistId); }); + _hideSelectionOverlay(); } void _exitPlaylistSelectionMode() { @@ -782,6 +951,7 @@ class _QueueTabState extends ConsumerState { _isPlaylistSelectionMode = false; _selectedPlaylistIds.clear(); }); + _hidePlaylistSelectionOverlay(); } void _togglePlaylistSelection(String playlistId) { @@ -1172,6 +1342,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = null; _sortMode = 'latest'; _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); }); } @@ -1400,6 +1571,46 @@ class _QueueTabState extends ConsumerState { return result; } + ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}) + _resolveFilteredGroupedAlbums(_HistoryStats historyStats) { + final cacheValid = + identical(_groupedAlbumFilterHistoryStatsCache, historyStats) && + _groupedAlbumFilterSearchQuery == _searchQuery && + _groupedAlbumFilterSource == _filterSource && + _groupedAlbumFilterQuality == _filterQuality && + _groupedAlbumFilterFormat == _filterFormat && + _groupedAlbumFilterSortMode == _sortMode; + + if (cacheValid) { + return ( + albums: _filteredGroupedAlbumsCache, + localAlbums: _filteredGroupedLocalAlbumsCache, + ); + } + + final filteredGroupedAlbums = _filterGroupedAlbums( + historyStats.groupedAlbums, + _searchQuery, + ); + final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( + historyStats.groupedLocalAlbums, + _searchQuery, + ); + + _groupedAlbumFilterHistoryStatsCache = historyStats; + _groupedAlbumFilterSearchQuery = _searchQuery; + _groupedAlbumFilterSource = _filterSource; + _groupedAlbumFilterQuality = _filterQuality; + _groupedAlbumFilterFormat = _filterFormat; + _groupedAlbumFilterSortMode = _sortMode; + _filteredGroupedAlbumsCache = filteredGroupedAlbums; + _filteredGroupedLocalAlbumsCache = filteredGroupedLocalAlbums; + return ( + albums: filteredGroupedAlbums, + localAlbums: filteredGroupedLocalAlbums, + ); + } + Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { @@ -1425,6 +1636,7 @@ class _QueueTabState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerLow, shape: const RoundedRectangleBorder( @@ -1433,201 +1645,224 @@ class _QueueTabState extends ConsumerState { builder: (context) => StatefulBuilder( builder: (context, setSheetState) { return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), + child: LayoutBuilder( + builder: (context, constraints) { + final maxSheetHeight = constraints.maxHeight * 0.9; + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxSheetHeight), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + Row( + children: [ + Text( + context.l10n.libraryFilterTitle, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const Spacer(), + TextButton( + onPressed: () { + setSheetState(() { + tempSource = null; + tempQuality = null; + tempFormat = null; + tempSortMode = 'latest'; + }); + }, + child: Text(context.l10n.libraryFilterReset), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterSource, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempSource == null, + onSelected: (_) => + setSheetState(() => tempSource = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterDownloaded, + ), + selected: tempSource == 'downloaded', + onSelected: (_) => setSheetState( + () => tempSource = 'downloaded', + ), + ), + FilterChip( + label: Text(context.l10n.libraryFilterLocal), + selected: tempSource == 'local', + onSelected: (_) => + setSheetState(() => tempSource = 'local'), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterQuality, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempQuality == null, + onSelected: (_) => + setSheetState(() => tempQuality = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityHiRes, + ), + selected: tempQuality == 'hires', + onSelected: (_) => + setSheetState(() => tempQuality = 'hires'), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityCD, + ), + selected: tempQuality == 'cd', + onSelected: (_) => + setSheetState(() => tempQuality = 'cd'), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityLossy, + ), + selected: tempQuality == 'lossy', + onSelected: (_) => + setSheetState(() => tempQuality = 'lossy'), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterFormat, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempFormat == null, + onSelected: (_) => + setSheetState(() => tempFormat = null), + ), + for (final format + in availableFormats.toList()..sort()) + FilterChip( + label: Text(format.toUpperCase()), + selected: tempFormat == format, + onSelected: (_) => + setSheetState(() => tempFormat = format), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterSort, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text( + context.l10n.libraryFilterSortLatest, + ), + selected: tempSortMode == 'latest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'latest', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortOldest, + ), + selected: tempSortMode == 'oldest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'oldest', + ), + ), + FilterChip( + label: const Text('A-Z'), + selected: tempSortMode == 'a-z', + onSelected: (_) => + setSheetState(() => tempSortMode = 'a-z'), + ), + FilterChip( + label: const Text('Z-A'), + selected: tempSortMode == 'z-a', + onSelected: (_) => + setSheetState(() => tempSortMode = 'z-a'), + ), + ], + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + setState(() { + _filterSource = tempSource; + _filterQuality = tempQuality; + _filterFormat = tempFormat; + _sortMode = tempSortMode; + _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); + }); + Navigator.pop(context); + }, + child: Text(context.l10n.libraryFilterApply), + ), + ), + ], ), ), ), - - Row( - children: [ - Text( - context.l10n.libraryFilterTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - TextButton( - onPressed: () { - setSheetState(() { - tempSource = null; - tempQuality = null; - tempFormat = null; - tempSortMode = 'latest'; - }); - }, - child: Text(context.l10n.libraryFilterReset), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterSource, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempSource == null, - onSelected: (_) => - setSheetState(() => tempSource = null), - ), - FilterChip( - label: Text(context.l10n.libraryFilterDownloaded), - selected: tempSource == 'downloaded', - onSelected: (_) => - setSheetState(() => tempSource = 'downloaded'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterLocal), - selected: tempSource == 'local', - onSelected: (_) => - setSheetState(() => tempSource = 'local'), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterQuality, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempQuality == null, - onSelected: (_) => - setSheetState(() => tempQuality = null), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityHiRes), - selected: tempQuality == 'hires', - onSelected: (_) => - setSheetState(() => tempQuality = 'hires'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityCD), - selected: tempQuality == 'cd', - onSelected: (_) => - setSheetState(() => tempQuality = 'cd'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityLossy), - selected: tempQuality == 'lossy', - onSelected: (_) => - setSheetState(() => tempQuality = 'lossy'), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempFormat == null, - onSelected: (_) => - setSheetState(() => tempFormat = null), - ), - for (final format in availableFormats.toList()..sort()) - FilterChip( - label: Text(format.toUpperCase()), - selected: tempFormat == format, - onSelected: (_) => - setSheetState(() => tempFormat = format), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterSort, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterSortLatest), - selected: tempSortMode == 'latest', - onSelected: (_) => - setSheetState(() => tempSortMode = 'latest'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterSortOldest), - selected: tempSortMode == 'oldest', - onSelected: (_) => - setSheetState(() => tempSortMode = 'oldest'), - ), - FilterChip( - label: const Text('A-Z'), - selected: tempSortMode == 'a-z', - onSelected: (_) => - setSheetState(() => tempSortMode = 'a-z'), - ), - FilterChip( - label: const Text('Z-A'), - selected: tempSortMode == 'z-a', - onSelected: (_) => - setSheetState(() => tempSortMode = 'z-a'), - ), - ], - ), - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - setState(() { - _filterSource = tempSource; - _filterQuality = tempQuality; - _filterFormat = tempFormat; - _sortMode = tempSortMode; - _unifiedItemsCache.clear(); - }); - Navigator.pop(context); - }, - child: Text(context.l10n.libraryFilterApply), - ), - ), - ], - ), + ); + }, ), ); }, @@ -1635,10 +1870,25 @@ class _QueueTabState extends ConsumerState { ); } - Future _openFile(String filePath) async { + Future _openFile( + String filePath, { + String title = '', + String artist = '', + String album = '', + String coverUrl = '', + }) async { final cleanPath = _cleanFilePath(filePath); try { - await openFile(cleanPath); + final fallbackTitle = cleanPath.split('/').last.split('\\').last; + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: cleanPath, + title: title.isNotEmpty ? title : fallbackTitle, + artist: artist, + album: album, + coverUrl: coverUrl, + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -2058,11 +2308,17 @@ class _QueueTabState extends ConsumerState { /// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view /// where the widget should expand to fill its parent. Widget _buildPlaylistCover( + BuildContext context, UserPlaylistCollection playlist, ColorScheme colorScheme, [ double? size, ]) { final borderRadius = BorderRadius.circular(8); + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheExtent = size != null + ? (size * dpr).round().clamp(64, 1024) + : 420; + final placeholder = _playlistIconFallback(colorScheme, size); final customCoverPath = playlist.coverImagePath; if (customCoverPath != null && customCoverPath.isNotEmpty) { @@ -2073,7 +2329,14 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheExtent, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -2096,7 +2359,14 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheExtent, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -2107,13 +2377,15 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - placeholder: (_, _) => _playlistIconFallback(colorScheme, size), - errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), + memCacheWidth: cacheExtent, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, ), ); } - return _playlistIconFallback(colorScheme, size); + return placeholder; } /// Icon fallback for playlists with no cover. @@ -2267,22 +2539,20 @@ class _QueueTabState extends ConsumerState { final historyStats = _historyStatsCache ?? _buildHistoryStats(allHistoryItems, localLibraryItems); - final groupedAlbums = historyStats.groupedAlbums; - final groupedLocalAlbums = historyStats.groupedLocalAlbums; - final filteredGroupedAlbums = _filterGroupedAlbums( - groupedAlbums, - _searchQuery, - ); - final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( - groupedLocalAlbums, - _searchQuery, - ); + final filteredGrouped = _resolveFilteredGroupedAlbums(historyStats); + final filteredGroupedAlbums = filteredGrouped.albums; + final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; - final filterDataCache = {}; + _prepareFilterContentCache( + allHistoryItems: allHistoryItems, + historyStats: historyStats, + localLibraryItems: localLibraryItems, + collectionState: collectionState, + ); _FilterContentData getFilterData(String filterMode) { - return filterDataCache.putIfAbsent( + return _filterContentDataCache.putIfAbsent( filterMode, () => _computeFilterContentData( filterMode: filterMode, @@ -2298,12 +2568,29 @@ class _QueueTabState extends ConsumerState { } final bottomPadding = MediaQuery.of(context).padding.bottom; + final selectionItems = getFilterData( + historyFilterMode, + ).filteredUnifiedItems; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncSelectionOverlay( + items: selectionItems, + bottomPadding: bottomPadding, + ); + _syncPlaylistSelectionOverlay( + playlists: collectionState.playlists, + bottomPadding: bottomPadding, + ); + }); return PopScope( - canPop: !_isSelectionMode, + canPop: !_isSelectionMode && !_isPlaylistSelectionMode, onPopInvokedWithResult: (didPop, result) { - if (!didPop && _isSelectionMode) { - _exitSelectionMode(); + if (!didPop) { + if (_isPlaylistSelectionMode) { + _exitPlaylistSelectionMode(); + } else if (_isSelectionMode) { + _exitSelectionMode(); + } } }, child: Stack( @@ -2485,172 +2772,33 @@ class _QueueTabState extends ConsumerState { ), ), ], - body: NotificationListener( - onNotification: (notification) { - final parentController = widget.parentPageController; - if (parentController == null || - !parentController.hasClients) { - return false; - } - - final page = _filterPageController!.page?.round() ?? 0; - - if (notification is OverscrollNotification) { - final overscroll = notification.overscroll; - - if (page == 0 && overscroll < 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - - if (page == 2 && overscroll > 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - } - - if (notification is ScrollEndNotification) { - if (page == 0 || page == 2) { - final currentPage = - parentController.page ?? - widget.parentPageIndex.toDouble(); - final historyPage = widget.parentPageIndex.toDouble(); - final offset = currentPage - historyPage; - - if (offset.abs() > 0.01) { - if (offset < -0.3) { - parentController.animateToPage( - widget.parentPageIndex - 1, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else if (offset > 0.3) { - parentController.animateToPage( - widget.nextPageIndex ?? - (widget.parentPageIndex + 1), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else { - parentController.jumpToPage(widget.parentPageIndex); - } - } - } - } - - return false; + body: PageView.builder( + controller: _filterPageController!, + physics: const ClampingScrollPhysics(), + onPageChanged: _onFilterPageChanged, + itemCount: _filterModes.length, + itemBuilder: (context, index) { + final filterMode = _filterModes[index]; + final filterData = getFilterData(filterMode); + return _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: filterMode, + historyViewMode: historyViewMode, + hasQueueItems: hasQueueItems, + filterData: filterData, + localLibraryItems: localLibraryItems, + collectionState: collectionState, + ); }, - child: PageView.builder( - controller: _filterPageController!, - physics: const ClampingScrollPhysics(), - onPageChanged: _onFilterPageChanged, - itemCount: _filterModes.length, - itemBuilder: (context, index) { - final filterMode = _filterModes[index]; - final filterData = getFilterData(filterMode); - return _buildFilterContent( - context: context, - colorScheme: colorScheme, - filterMode: filterMode, - historyViewMode: historyViewMode, - hasQueueItems: hasQueueItems, - filterData: filterData, - localLibraryItems: localLibraryItems, - collectionState: collectionState, - ); - }, - ), ), ), ), // ScrollConfiguration - - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _isSelectionMode - ? _buildSelectionBottomBar( - context, - colorScheme, - _buildUnifiedItemsForSelection( - filterMode: historyFilterMode, - allHistoryItems: allHistoryItems, - albumCounts: historyStats.albumCounts, - localLibraryItems: localLibraryItems, - localAlbumCounts: historyStats.localAlbumCounts, - collectionState: collectionState, - ), - bottomPadding, - ) - : const SizedBox.shrink(), - ), - - // Playlist selection bottom bar - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isPlaylistSelectionMode ? 0 : -(200 + bottomPadding), - child: _isPlaylistSelectionMode - ? _buildPlaylistSelectionBottomBar( - context, - colorScheme, - collectionState.playlists, - bottomPadding, - ) - : const SizedBox.shrink(), - ), ], ), ); } - /// Build unified items list for selection mode - List _buildUnifiedItemsForSelection({ - required String filterMode, - required List allHistoryItems, - required Map albumCounts, - required List localLibraryItems, - required Map localAlbumCounts, - required LibraryCollectionsState collectionState, - }) { - final historyItems = _resolveHistoryItems( - filterMode: filterMode, - allHistoryItems: allHistoryItems, - albumCounts: albumCounts, - ); - - final unifiedItems = _getUnifiedItems( - filterMode: filterMode, - historyItems: historyItems, - localLibraryItems: localLibraryItems, - localAlbumCounts: localAlbumCounts, - ); - - // Apply advanced filters to match what's displayed - final filtered = _applyAdvancedFilters(unifiedItems); - - if (!collectionState.hasPlaylistTracks) return filtered; - return filtered - .where( - (item) => !collectionState.isTrackInAnyPlaylist(item.collectionKey), - ) - .toList(growable: false); - } - List _getUnifiedItems({ required String filterMode, required List historyItems, @@ -3016,7 +3164,11 @@ class _QueueTabState extends ConsumerState { _buildCollectionGridItem( context: context, colorScheme: colorScheme, - coverWidget: _buildPlaylistCover(playlist, colorScheme), + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + ), title: playlist.name, count: playlist.tracks.length, onTap: _isPlaylistSelectionMode @@ -3031,14 +3183,16 @@ class _QueueTabState extends ConsumerState { left: 0, top: 0, right: 0, - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), + child: IgnorePointer( + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), ), ), ), @@ -3047,26 +3201,28 @@ class _QueueTabState extends ConsumerState { Positioned( top: 4, right: 4, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( color: isSelected ? colorScheme.primary - : colorScheme.outline, - width: 2, + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), + child: isSelected + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 16, height: 16), ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), ), ), ], @@ -3138,35 +3294,44 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ if (_isPlaylistSelectionMode) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( + GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( color: isSelected ? colorScheme.primary - : colorScheme.outline, - width: 2, + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), + child: isSelected + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 18, height: 18), ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), ), ), Expanded( child: _buildCollectionListItem( context: context, colorScheme: colorScheme, - coverWidget: _buildPlaylistCover(playlist, colorScheme, 56), + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + 56, + ), title: playlist.name, subtitle: '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', @@ -3844,8 +4009,9 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - Text( - album.artistName, + ClickableArtistName( + artistName: album.artistName, + coverUrl: album.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -3950,8 +4116,8 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - Text( - album.artistName, + ClickableArtistName( + artistName: album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -4264,15 +4430,20 @@ class _QueueTabState extends ConsumerState { } /// Show batch convert bottom sheet for selected tracks - void _showBatchConvertSheet( + Future _showBatchConvertSheet( BuildContext context, List allItems, - ) { + ) async { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + var didStartConversion = false; - showModalBottomSheet( + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + + await showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -4367,6 +4538,7 @@ class _QueueTabState extends ConsumerState { width: double.infinity, child: FilledButton( onPressed: () { + didStartConversion = true; Navigator.pop(context); _performBatchConversion( allItems: allItems, @@ -4395,6 +4567,19 @@ class _QueueTabState extends ConsumerState { ); }, ); + + if (!mounted || didStartConversion) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } else if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: ref.read(libraryCollectionsProvider).playlists, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } } /// Perform batch conversion on selected tracks @@ -4964,8 +5149,10 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - item.track.artistName, + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -5111,7 +5298,13 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile(item.filePath!), + onPressed: () => _openFile( + item.filePath!, + title: item.track.name, + artist: item.track.artistName, + album: item.track.albumName, + coverUrl: item.track.coverUrl ?? '', + ), icon: Icon(Icons.play_arrow, color: colorScheme.primary), tooltip: 'Play', style: IconButton.styleFrom( @@ -5417,7 +5610,13 @@ class _QueueTabState extends ConsumerState { ? () => _navigateToHistoryMetadataScreen(item.historyItem!) : item.localItem != null ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), @@ -5469,8 +5668,9 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - item.artistName, + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -5556,7 +5756,14 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile(item.filePath), + onPressed: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? item.localCoverPath ?? '', + ), icon: Icon( Icons.play_arrow, color: colorScheme.primary, @@ -5601,7 +5808,13 @@ class _QueueTabState extends ConsumerState { ? () => _navigateToHistoryMetadataScreen(item.historyItem!) : item.localItem != null ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), child: Stack( children: [ @@ -5676,7 +5889,16 @@ class _QueueTabState extends ConsumerState { builder: (context, fileExists, child) { return fileExists ? GestureDetector( - onTap: () => _openFile(item.filePath), + onTap: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? + item.localCoverPath ?? + '', + ), child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( @@ -5727,8 +5949,9 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), - Text( - item.artistName, + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 142035af..a348ecac 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,6 +6,8 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class SearchScreen extends ConsumerStatefulWidget { final String query; @@ -61,9 +63,10 @@ class _SearchScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final trackState = ref.watch(trackProvider); + final tracks = ref.watch(trackProvider.select((s) => s.tracks)); + final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); + final error = ref.watch(trackProvider.select((s) => s.error)); final colorScheme = Theme.of(context).colorScheme; - final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -86,15 +89,11 @@ class _SearchScreenState extends ConsumerState { ), body: Column( children: [ - if (trackState.isLoading) - LinearProgressIndicator(color: colorScheme.primary), - if (trackState.error != null) + if (isLoading) LinearProgressIndicator(color: colorScheme.primary), + if (error != null) Padding( padding: const EdgeInsets.all(16.0), - child: Text( - trackState.error!, - style: TextStyle(color: colorScheme.error), - ), + child: Text(error, style: TextStyle(color: colorScheme.error)), ), Expanded( child: tracks.isEmpty @@ -159,14 +158,19 @@ class _SearchScreenState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - track.artistName, + ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), - Text( - track.albumName, + 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( @@ -175,7 +179,21 @@ class _SearchScreenState extends ConsumerState { ), ], ), - trailing: null, + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.download_rounded), + tooltip: 'Download', + onPressed: () => _downloadTrack(track), + ), + ], + ), onTap: () => _downloadTrack(track), ); } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 17a174a6..4778e95f 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 44944d49..a1933808 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -166,6 +167,9 @@ class _RecentDonorsCard extends StatelessWidget { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; const donorNames = [ + 'NinoBrown', + '@nino_sandzak', + 'IMJ', 'J', 'Julian', 'matt_3050', @@ -282,6 +286,19 @@ class _DonateLinksCard extends StatelessWidget { url: AppInfo.githubSponsorsUrl, colorScheme: colorScheme, ), + Divider( + height: 1, + thickness: 1, + indent: 74, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + _CryptoWalletItem( + title: 'USDT (TRC20)', + walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta', + color: const Color(0xFF26A17B), + colorScheme: colorScheme, + ), ], ), ); @@ -357,13 +374,97 @@ class _DonateCardItem extends StatelessWidget { } } +class _CryptoWalletItem extends StatelessWidget { + final String title; + final String walletAddress; + final Color color; + final ColorScheme colorScheme; + + const _CryptoWalletItem({ + required this.title, + required this.walletAddress, + required this.color, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: walletAddress)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$title address copied to clipboard'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '\$', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + walletAddress, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.copy_rounded, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + int _cr(String v) { int r = 0x1F; for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } return r; } -// Highlighted supporters (hashes of names): Julian, J. -const _cv = {1825257268, 1035}; +// Highlighted supporters (hashes of names): Julian, J, NinoBrown, @nino_sandzak, IMJ. +const _cv = {1825257268, 1035, 1497948283, 398058782, 996135}; class _SupporterChip extends StatelessWidget { final String name; diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 389629f1..37918235 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -501,14 +501,17 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsSwitchItem( icon: Icons.subtitles_outlined, title: context.l10n.optionsEmbedLyrics, - subtitle: context.l10n.optionsEmbedLyricsSubtitle, + subtitle: settings.embedMetadata + ? context.l10n.optionsEmbedLyricsSubtitle + : 'Disabled while Embed Metadata is turned off', value: settings.embedLyrics, + enabled: settings.embedMetadata, onChanged: (value) => ref .read(settingsProvider.notifier) .setEmbedLyrics(value), - showDivider: settings.embedLyrics, + showDivider: settings.embedMetadata && settings.embedLyrics, ), - if (settings.embedLyrics) ...[ + if (settings.embedMetadata && settings.embedLyrics) ...[ SettingsItem( icon: Icons.lyrics_outlined, title: context.l10n.lyricsMode, @@ -858,6 +861,7 @@ class _DownloadSettingsPageState extends ConsumerState { ) { showModalBottomSheet( context: context, + useRootNavigator: true, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -992,6 +996,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -1209,6 +1214,7 @@ class _DownloadSettingsPageState extends ConsumerState { settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1288,6 +1294,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1451,6 +1458,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1516,6 +1524,7 @@ class _DownloadSettingsPageState extends ConsumerState { } static const _providerDisplayNames = { + 'spotify_api': 'Spotify Lyrics API', 'lrclib': 'LRCLIB', 'netease': 'Netease', 'musixmatch': 'Musixmatch', @@ -1544,6 +1553,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1604,6 +1614,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1702,6 +1713,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1786,6 +1798,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1857,6 +1870,7 @@ class _DownloadSettingsPageState extends ConsumerState { final normalizedCurrent = current.trim().toUpperCase(); showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -1924,6 +1938,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -2024,16 +2039,13 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); + final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) .toList(); - final isExtensionService = ![ - 'tidal', - 'qobuz', - 'amazon', - ].contains(currentService); + final isExtensionService = !builtInServiceIds.contains(currentService); final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) : true; @@ -2046,47 +2058,56 @@ class _ServiceSelector extends ConsumerWidget { children: [ Row( children: [ - _ServiceChip( - icon: Icons.music_note, - label: 'Tidal', - isSelected: effectiveService == 'tidal', - onTap: () => onChanged('tidal'), + Expanded( + child: _ServiceChip( + icon: Icons.music_note, + label: 'Tidal', + isSelected: effectiveService == 'tidal', + onTap: () => onChanged('tidal'), + ), ), const SizedBox(width: 8), - _ServiceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: effectiveService == 'qobuz', - onTap: () => onChanged('qobuz'), + Expanded( + child: _ServiceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: effectiveService == 'qobuz', + onTap: () => onChanged('qobuz'), + ), ), const SizedBox(width: 8), - _ServiceChip( - icon: Icons.shopping_bag_outlined, - label: 'Amazon', - isSelected: effectiveService == 'amazon', - onTap: () => onChanged('amazon'), + Expanded( + child: _ServiceChip( + icon: Icons.shopping_bag_outlined, + label: 'Amazon', + isSelected: effectiveService == 'amazon', + onTap: () => onChanged('amazon'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ServiceChip( + icon: Icons.smart_display, + label: 'YouTube', + isSelected: effectiveService == 'youtube', + onTap: () => onChanged('youtube'), + ), ), ], ), if (extensionProviders.isNotEmpty) ...[ const SizedBox(height: 8), - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ - for (int i = 0; i < extensionProviders.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.extension, - label: extensionProviders[i].displayName, - isSelected: effectiveService == extensionProviders[i].id, - onTap: () => onChanged(extensionProviders[i].id), - ), + for (final extension in extensionProviders) + _ServiceChip( + icon: Icons.extension, + label: extension.displayName, + isSelected: effectiveService == extension.id, + onTap: () => onChanged(extension.id), ), - ], - for (int i = extensionProviders.length; i < 3; i++) ...[ - const SizedBox(width: 8), - const Expanded(child: SizedBox()), - ], ], ), ], @@ -2120,38 +2141,35 @@ class _ServiceChip extends StatelessWidget { ) : colorScheme.surfaceContainerHigh; - return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, + return Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - children: [ - Icon( - icon, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), ), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 5c13458b..d339e24d 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget { } class _ExtensionsPageState extends ConsumerState { - static final RegExp _platformExceptionPattern = - RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),'); - static final RegExp _platformExceptionSimplePattern = - RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null'); - static final RegExp _trailingNullsPattern = - RegExp(r',\s*null\s*,\s*null\)?$'); + static final RegExp _platformExceptionPattern = RegExp( + r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),', + ); + static final RegExp _platformExceptionSimplePattern = RegExp( + r'PlatformException\([^,]+,\s*(.+?),\s*null', + ); + static final RegExp _trailingNullsPattern = RegExp( + r',\s*null\s*,\s*null\)?$', + ); static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*'); @override @@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState { final appDir = await getApplicationDocumentsDirectory(); final extensionsDir = '${appDir.path}/extensions'; final dataDir = '${appDir.path}/extension_data'; - + await Directory(extensionsDir).create(recursive: true); await Directory(dataDir).create(recursive: true); - - await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); + + await ref + .read(extensionProvider.notifier) + .initialize(extensionsDir, dataDir); } } @@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState { child: Scaffold( body: CustomScrollView( slivers: [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - 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); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - context.l10n.extensionsTitle, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + 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); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, ), - ), - ); - }, - ), - ), - - if (extState.isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + title: Text( + context.l10n.extensionsTitle, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, ), ), - if (extState.error != null) + if (extState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + + if (extState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + extState.error!, + style: TextStyle( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionsProviderPrioritySection, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _DownloadPriorityItem(), + _MetadataPriorityItem(), + _SearchProviderSelector(), + ], + ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionsInstalledSection, + ), + ), + + if (extState.extensions.isEmpty && !extState.isLoading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + Icons.extension_outlined, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.extensionsNoExtensions, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + context.l10n.extensionsNoExtensionsSubtitle, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + if (extState.extensions.isNotEmpty) + SliverToBoxAdapter( + child: SettingsGroup( + children: extState.extensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < extState.extensions.length - 1, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + ExtensionDetailPage(extensionId: ext.id), + ), + ), + onToggle: (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(ext.id, enabled), + ); + }).toList(), + ), + ), + SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: Text(context.l10n.extensionsInstallButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: colorScheme.errorContainer, + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(Icons.error_outline, color: colorScheme.error), + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), const SizedBox(width: 12), Expanded( child: Text( - extState.error!, - style: TextStyle(color: colorScheme.onErrorContainer), + context.l10n.extensionsInfoTip, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), ), ), ], @@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState { ), ), ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _DownloadPriorityItem(), - _MetadataPriorityItem(), - _SearchProviderSelector(), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection), - ), - - if (extState.extensions.isEmpty && !extState.isLoading) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - Icon( - Icons.extension_outlined, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - context.l10n.extensionsNoExtensions, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.extensionsNoExtensionsSubtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - - if (extState.extensions.isNotEmpty) - SliverToBoxAdapter( - child: SettingsGroup( - children: extState.extensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < extState.extensions.length - 1, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ExtensionDetailPage(extensionId: ext.id), - ), - ), - onToggle: (enabled) => ref - .read(extensionProvider.notifier) - .setExtensionEnabled(ext.id, enabled), - ); - }).toList(), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: _installExtension, - icon: const Icon(Icons.add), - label: Text(context.l10n.extensionsInstallButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.extensionsInfoTip, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - ], + ], + ), ), - ), ); } @@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState { if (!file.path!.endsWith('.spotiflac-ext')) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarSelectExtFile), - ), + SnackBar(content: Text(context.l10n.snackbarSelectExtFile)), ); } return; @@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState { } else { message = _getFriendlyErrorMessage(extState.error); } - + ref.read(extensionProvider.notifier).clearError(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } } } @@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState { /// Parse error message to be more user-friendly String _getFriendlyErrorMessage(String? error) { if (error == null) return 'Failed to install extension'; - + String message = error; - + if (message.contains('PlatformException')) { final match = _platformExceptionPattern.firstMatch(message); if (match != null) { @@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState { } } } - + message = message.replaceAll(_trailingNullsPattern, ''); message = message.replaceAll(_leadingCommaPattern, ''); - + return message; } } @@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget { : colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), - child: extension.iconPath != null && extension.iconPath!.isNotEmpty + child: + extension.iconPath != null && + extension.iconPath!.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file( @@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 2), Text( hasError - ? extension.errorMessage ?? context.l10n.extensionsErrorLoading + ? extension.errorMessage ?? + context.l10n.extensionsErrorLoading : 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: hasError @@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - - final hasDownloadExtensions = extState.extensions - .any((e) => e.enabled && e.hasDownloadProvider); - + + final hasDownloadExtensions = extState.extensions.any( + (e) => e.enabled && e.hasDownloadProvider, + ); + return InkWell( - onTap: hasDownloadExtensions + onTap: hasDownloadExtensions ? () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const ProviderPriorityPage(), - ), + MaterialPageRoute(builder: (_) => const ProviderPriorityPage()), ) : null, child: Padding( @@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget { children: [ Icon( Icons.download, - color: hasDownloadExtensions - ? colorScheme.onSurfaceVariant + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), const SizedBox(width: 16), @@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget { Text( context.l10n.extensionsDownloadPriority, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: hasDownloadExtensions - ? null - : colorScheme.outline, + color: hasDownloadExtensions ? null : colorScheme.outline, ), ), const SizedBox(height: 2), Text( - hasDownloadExtensions + hasDownloadExtensions ? context.l10n.extensionsDownloadPrioritySubtitle : context.l10n.extensionsNoDownloadProvider, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: hasDownloadExtensions - ? colorScheme.onSurfaceVariant + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), ], @@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - - final hasMetadataExtensions = extState.extensions - .any((e) => e.enabled && e.hasMetadataProvider); - + + final hasMetadataExtensions = extState.extensions.any( + (e) => e.enabled && e.hasMetadataProvider, + ); + return InkWell( - onTap: hasMetadataExtensions + onTap: hasMetadataExtensions ? () => Navigator.push( context, MaterialPageRoute( @@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget { children: [ Icon( Icons.search, - color: hasMetadataExtensions - ? colorScheme.onSurfaceVariant + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), const SizedBox(width: 16), @@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget { Text( context.l10n.extensionsMetadataPriority, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: hasMetadataExtensions - ? null - : colorScheme.outline, + color: hasMetadataExtensions ? null : colorScheme.outline, ), ), const SizedBox(height: 2), Text( - hasMetadataExtensions + hasMetadataExtensions ? context.l10n.extensionsMetadataPrioritySubtitle : context.l10n.extensionsNoMetadataProvider, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: hasMetadataExtensions - ? colorScheme.onSurfaceVariant + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), ], @@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - + final searchProviders = extState.extensions .where((e) => e.enabled && e.hasCustomSearch) .toList(); - + String currentProviderName = context.l10n.extensionDefaultProvider; - if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull; + if (settings.searchProvider != null && + settings.searchProvider!.isNotEmpty) { + final ext = searchProviders + .where((e) => e.id == settings.searchProvider) + .firstOrNull; currentProviderName = ext?.displayName ?? settings.searchProvider!; } - + return Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: searchProviders.isEmpty - ? null - : () => _showSearchProviderPicker(context, ref, settings, searchProviders), + onTap: searchProviders.isEmpty + ? null + : () => _showSearchProviderPicker( + context, + ref, + settings, + searchProviders, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon( Icons.manage_search, - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : colorScheme.onSurfaceVariant, ), const SizedBox(width: 16), @@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget { Text( context.l10n.extensionsSearchProvider, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : null, ), ), const SizedBox(height: 2), Text( - searchProviders.isEmpty + searchProviders.isEmpty ? context.l10n.extensionsNoCustomSearch : currentProviderName, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : colorScheme.onSurfaceVariant, ), ], @@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget { List searchProviders, ) { final colorScheme = Theme.of(context).colorScheme; - + showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( ctx.l10n.extensionsSearchProvider, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( @@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget { leading: Icon(Icons.music_note, color: colorScheme.primary), title: Text(ctx.l10n.extensionDefaultProvider), subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), - trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty) + trailing: + (settings.searchProvider == null || + settings.searchProvider!.isEmpty) ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), onTap: () { @@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget { Navigator.pop(ctx); }, ), - ...searchProviders.map((ext) => ListTile( - leading: Icon(Icons.extension, color: colorScheme.secondary), - title: Text(ext.displayName), - subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch), - trailing: settings.searchProvider == ext.id - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(ext.id); - Navigator.pop(ctx); - }, - )), + ...searchProviders.map( + (ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text( + ext.searchBehavior?.placeholder ?? + ctx.l10n.extensionsCustomSearch, + ), + trailing: settings.searchProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + Navigator.pop(ctx); + }, + ), + ), const SizedBox(height: 16), ], ), diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index b203717f..a33e8a50 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState extends ConsumerState { static const _allProviderIds = [ 'lrclib', + 'spotify_api', 'netease', 'musixmatch', 'apple_music', @@ -183,6 +184,12 @@ class _LyricsProviderPriorityPageState static _LyricsProviderInfo _getLyricsProviderInfo(String id) { switch (id) { + case 'spotify_api': + return _LyricsProviderInfo( + name: 'Spotify Lyrics API', + description: 'Spotify-sourced synced lyrics via community API', + icon: Icons.music_note_outlined, + ); case 'lrclib': return _LyricsProviderInfo( name: 'LRCLIB', diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 013bc6e0..dbd4669c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -152,6 +152,30 @@ class OptionsSettingsPage extends ConsumerWidget { onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), ), + SettingsSwitchItem( + icon: Icons.skip_next_rounded, + title: context.l10n.optionsAutoSkipUnavailableTracks, + subtitle: settings.autoSkipUnavailableTracks + ? context + .l10n + .optionsAutoSkipUnavailableTracksSubtitleOn + : context + .l10n + .optionsAutoSkipUnavailableTracksSubtitleOff, + value: settings.autoSkipUnavailableTracks, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setAutoSkipUnavailableTracks(v), + ), + SettingsSwitchItem( + icon: Icons.queue_music_rounded, + title: context.l10n.settingsSmartQueueTitle, + subtitle: context.l10n.settingsSmartQueueSubtitle, + value: settings.smartQueueEnabled, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setSmartQueueEnabled(v), + ), if (hasExtensions) SettingsSwitchItem( icon: Icons.extension, @@ -164,11 +188,24 @@ class OptionsSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setUseExtensionProviders(v), ), + SettingsSwitchItem( + icon: Icons.sell_outlined, + title: 'Embed Metadata', + subtitle: settings.embedMetadata + ? 'Write metadata, cover art, and embedded lyrics to files' + : 'Disabled (advanced): skip all metadata embedding', + value: settings.embedMetadata, + onChanged: (v) => + ref.read(settingsProvider.notifier).setEmbedMetadata(v), + ), SettingsSwitchItem( icon: Icons.image, title: context.l10n.optionsMaxQualityCover, - subtitle: context.l10n.optionsMaxQualityCoverSubtitle, + subtitle: settings.embedMetadata + ? context.l10n.optionsMaxQualityCoverSubtitle + : 'Disabled when metadata embedding is off', value: settings.maxQualityCover, + enabled: settings.embedMetadata, onChanged: (v) => ref .read(settingsProvider.notifier) .setMaxQualityCover(v), @@ -375,6 +412,7 @@ class OptionsSettingsPage extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -972,9 +1010,9 @@ class _MetadataSourceSelector extends ConsumerWidget { Expanded( child: Text( context.l10n.optionsSpotifyDeprecationWarning, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.error), ), ), ], diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index b20ef45d..ed44ca46 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget { const ProviderPriorityPage({super.key}); @override - ConsumerState createState() => _ProviderPriorityPageState(); + ConsumerState createState() => + _ProviderPriorityPageState(); } class _ProviderPriorityPageState extends ConsumerState { @@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState { void _loadProviders() { final extState = ref.read(extensionProvider); - final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders(); - + final allProviders = ref + .read(extensionProvider.notifier) + .getAllDownloadProviders(); + if (extState.providerPriority.isNotEmpty) { _providers = List.from(extState.providerPriority); for (final provider in allProviders) { @@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), title: Text( context.l10n.providerPriorityTitle, style: TextStyle( @@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), const SizedBox(width: 12), Expanded( child: Text( context.l10n.providerPriorityInfo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), ), ), ], @@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget { ), ), Text( - info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension, + info.isBuiltIn + ? context.l10n.providerBuiltIn + : context.l10n.providerExtension, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget { ], ), ), - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget { isBuiltIn: true, ); case 'qobuz': - return _ProviderInfo( - name: 'Qobuz', - icon: Icons.album, - isBuiltIn: true, - ); + return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true); case 'amazon': return _ProviderInfo( name: 'Amazon Music', icon: Icons.shopping_bag, isBuiltIn: true, ); + case 'youtube': + return _ProviderInfo( + name: 'YouTube', + icon: Icons.play_circle_outline, + isBuiltIn: true, + ); default: return _ProviderInfo( name: provider, diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index e32204d0..8ec82457 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -30,11 +31,8 @@ class _SetupScreenState extends ConsumerState { bool _isLoading = false; int _androidSdkVersion = 0; - // Spotify form - final _clientIdController = TextEditingController(); - final _clientSecretController = TextEditingController(); - bool _useSpotifyApi = false; - bool _showClientSecret = false; + // Mode selection + String _selectedMode = 'downloader'; // We add 1 for the Welcome step int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1; @@ -48,8 +46,6 @@ class _SetupScreenState extends ConsumerState { @override void dispose() { _pageController.dispose(); - _clientIdController.dispose(); - _clientSecretController.dispose(); super.dispose(); } @@ -291,6 +287,7 @@ class _SetupScreenState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; await showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -339,8 +336,13 @@ class _SetupScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(validation.errorReason ?? 'Invalid folder selected'), - backgroundColor: Theme.of(context).colorScheme.error, + content: Text( + validation.errorReason ?? + 'Invalid folder selected', + ), + backgroundColor: Theme.of( + context, + ).colorScheme.error, duration: const Duration(seconds: 4), ), ); @@ -402,20 +404,10 @@ class _SetupScreenState extends ConsumerState { ); } - if (_useSpotifyApi && - _clientIdController.text.trim().isNotEmpty && - _clientSecretController.text.trim().isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setSpotifyCredentials( - _clientIdController.text.trim(), - _clientSecretController.text.trim(), - ); - ref.read(settingsProvider.notifier).setMetadataSource('spotify'); - } else { - ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - } - + ref.read(settingsProvider.notifier).setMetadataSource('deezer'); + await ref + .read(extensionProvider.notifier) + .ensureSpotifyWebExtensionReady(); ref.read(settingsProvider.notifier).setFirstLaunchComplete(); if (mounted) context.go('/tutorial'); @@ -475,7 +467,7 @@ class _SetupScreenState extends ConsumerState { case 2: return _selectedDirectory != null; case 3: - return false; // Spotify is last/submit + return true; // Mode selection always has a default } } else { switch (logicStep) { @@ -484,7 +476,7 @@ class _SetupScreenState extends ConsumerState { case 1: return _selectedDirectory != null; case 2: - return false; // Spotify + return true; // Mode selection always has a default } } return false; @@ -561,7 +553,7 @@ class _SetupScreenState extends ConsumerState { if (_androidSdkVersion >= 33) _buildNotificationStep(colorScheme), _buildDirectoryStep(colorScheme), - _buildSpotifyStep(colorScheme), + _buildModeSelectionStep(colorScheme), ], ), ), @@ -581,12 +573,7 @@ class _SetupScreenState extends ConsumerState { icon: const SizedBox.shrink(), // Custom layout ) : FloatingActionButton.extended( - onPressed: - (!_useSpotifyApi || - (_clientIdController.text.isNotEmpty && - _clientSecretController.text.isNotEmpty)) - ? _completeSetup - : null, + onPressed: _isLoading ? null : _completeSetup, label: _isLoading ? SizedBox( width: 20, @@ -761,106 +748,32 @@ class _SetupScreenState extends ConsumerState { ); } - Widget _buildSpotifyStep(ColorScheme colorScheme) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), + Widget _buildModeSelectionStep(ColorScheme colorScheme) { + return _StepLayout( + title: context.l10n.setupModeSelectionTitle, + description: context.l10n.setupModeSelectionDescription, + icon: Icons.tune, child: Column( children: [ - Icon(Icons.api, size: 48, color: colorScheme.primary), - const SizedBox(height: 24), - Text( - context.l10n.setupSpotifyApiOptional, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, + _ModeCard( + icon: Icons.download, + title: context.l10n.setupModeDownloaderTitle, + features: [ + context.l10n.setupModeDownloaderFeature1, + context.l10n.setupModeDownloaderFeature2, + context.l10n.setupModeDownloaderFeature3, + ], + isSelected: _selectedMode == 'downloader', + onTap: () => setState(() => _selectedMode = 'downloader'), + colorScheme: colorScheme, ), - const SizedBox(height: 8), + const SizedBox(height: 16), Text( - context.l10n.setupSpotifyApiDescription, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + context.l10n.setupModeChangeableLater, + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), - ), - const SizedBox(height: 32), - - Card( - elevation: 0, - color: colorScheme.surfaceContainerHighest, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - SwitchListTile( - value: _useSpotifyApi, - onChanged: (v) => setState(() => _useSpotifyApi = v), - title: Text(context.l10n.setupUseSpotifyApi), - subtitle: Text( - _useSpotifyApi - ? context.l10n.setupEnterCredentialsBelow - : "Using bundled metadata", - ), - ), - if (_useSpotifyApi) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - TextField( - controller: _clientIdController, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientId, - prefixIcon: const Icon(Icons.key), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline, - width: 0.5, - ), - ), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _clientSecretController, - obscureText: !_showClientSecret, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientSecret, - prefixIcon: const Icon(Icons.lock), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline, - width: 0.5, - ), - ), - suffixIcon: IconButton( - icon: Icon( - _showClientSecret - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () => setState( - () => _showClientSecret = !_showClientSecret, - ), - ), - ), - ), - ], - ), - ), - ], - ], - ), + textAlign: TextAlign.center, ), ], ), @@ -975,3 +888,126 @@ class _SuccessCard extends StatelessWidget { ); } } + +class _ModeCard extends StatelessWidget { + final IconData icon; + final String title; + final List features; + final bool isSelected; + final VoidCallback onTap; + final ColorScheme colorScheme; + + const _ModeCard({ + required this.icon, + required this.title, + required this.features, + required this.isSelected, + required this.onTap, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outlineVariant, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 22, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + size: 22, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...features.map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\u2022 ', + style: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: Text( + feature, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index d6689886..d8d83e7b 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -43,7 +43,30 @@ class _StoreTabState extends ConsumerState { @override Widget build(BuildContext context) { - final state = ref.watch(storeProvider); + final storeFilterState = ref.watch( + storeProvider.select( + (s) => (s.extensions, s.selectedCategory, s.searchQuery), + ), + ); + final extensions = storeFilterState.$1; + final selectedCategory = storeFilterState.$2; + final searchQuery = storeFilterState.$3; + final isLoading = ref.watch(storeProvider.select((s) => s.isLoading)); + final error = ref.watch(storeProvider.select((s) => s.error)); + final downloadingId = ref.watch( + storeProvider.select((s) => s.downloadingId), + ); + final filteredExtensions = StoreState( + extensions: extensions, + selectedCategory: selectedCategory, + searchQuery: searchQuery, + ).filteredExtensions; + if (_searchController.text != searchQuery) { + _searchController.value = TextEditingValue( + text: searchQuery, + selection: TextSelection.collapsed(offset: searchQuery.length), + ); + } final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); @@ -89,41 +112,46 @@ class _StoreTabState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.storeSearch, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - ref - .read(storeProvider.notifier) - .setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - ref.read(storeProvider.notifier).setSearchQuery(value); - setState(() {}); + child: ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + return TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.storeSearch, + prefixIcon: const Icon(Icons.search), + suffixIcon: value.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: + Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + ref.read(storeProvider.notifier).setSearchQuery(value); + }, + ); }, ), ), @@ -141,7 +169,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterAll, icon: Icons.apps, - isSelected: state.selectedCategory == null, + isSelected: selectedCategory == null, onTap: () => ref.read(storeProvider.notifier).setCategory(null), ), @@ -149,8 +177,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterMetadata, icon: Icons.label_outline, - isSelected: - state.selectedCategory == StoreCategory.metadata, + isSelected: selectedCategory == StoreCategory.metadata, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.metadata), @@ -159,8 +186,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterDownload, icon: Icons.download_outlined, - isSelected: - state.selectedCategory == StoreCategory.download, + isSelected: selectedCategory == StoreCategory.download, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.download), @@ -169,8 +195,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterUtility, icon: Icons.build_outlined, - isSelected: - state.selectedCategory == StoreCategory.utility, + isSelected: selectedCategory == StoreCategory.utility, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.utility), @@ -179,8 +204,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterLyrics, icon: Icons.lyrics_outlined, - isSelected: - state.selectedCategory == StoreCategory.lyrics, + isSelected: selectedCategory == StoreCategory.lyrics, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.lyrics), @@ -189,8 +213,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterIntegration, icon: Icons.link, - isSelected: - state.selectedCategory == StoreCategory.integration, + isSelected: selectedCategory == StoreCategory.integration, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.integration), @@ -200,22 +223,26 @@ class _StoreTabState extends ConsumerState { ), ), - if (state.isLoading && state.extensions.isEmpty) + if (isLoading && extensions.isEmpty) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (state.error != null && state.extensions.isEmpty) + else if (error != null && extensions.isEmpty) + SliverFillRemaining(child: _buildErrorState(error, colorScheme)) + else if (filteredExtensions.isEmpty) SliverFillRemaining( - child: _buildErrorState(state.error!, colorScheme), + child: _buildEmptyState( + hasFilters: + searchQuery.isNotEmpty || selectedCategory != null, + colorScheme: colorScheme, + ), ) - else if (state.filteredExtensions.isEmpty) - SliverFillRemaining(child: _buildEmptyState(state, colorScheme)) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( - '${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -227,16 +254,13 @@ class _StoreTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SettingsGroup( - children: state.filteredExtensions.asMap().entries.map(( - entry, - ) { + children: filteredExtensions.asMap().entries.map((entry) { final index = entry.key; final ext = entry.value; return _ExtensionItem( extension: ext, - showDivider: - index < state.filteredExtensions.length - 1, - isDownloading: state.downloadingId == ext.id, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, onInstall: () => _installExtension(ext), onUpdate: () => _updateExtension(ext), onTap: () => _showExtensionDetails(ext), @@ -288,10 +312,10 @@ class _StoreTabState extends ConsumerState { ); } - Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { - final hasFilters = - state.searchQuery.isNotEmpty || state.selectedCategory != null; - + Widget _buildEmptyState({ + required bool hasFilters, + required ColorScheme colorScheme, + }) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -541,7 +565,10 @@ class _ExtensionItem extends StatelessWidget { if (extension.requiresNewerApp) ...[ const SizedBox(height: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(4), @@ -549,14 +576,19 @@ class _ExtensionItem extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer), + 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, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -565,9 +597,8 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 4), Text( extension.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index b8024977..c34e7400 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,6 +13,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -2336,6 +2337,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -2566,6 +2568,7 @@ class _TrackMetadataScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -3003,6 +3006,7 @@ class _TrackMetadataScreenState extends ConsumerState { final saved = await showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -3039,6 +3043,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { showDialog( context: context, + useRootNavigator: false, builder: (context) => AlertDialog( title: Text(context.l10n.trackDeleteConfirmTitle), content: Text(context.l10n.trackDeleteConfirmMessage), @@ -3088,7 +3093,15 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openFile(BuildContext context, String filePath) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: filePath, + title: trackName, + artist: artistName, + album: albumName, + coverUrl: _coverUrl ?? '', + ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 5d77fa4e..6cb77e0d 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -106,6 +106,8 @@ class CsvImportService { artistName: trackData['artists'] as String? ?? track.artistName, albumName: trackData['album_name'] as String? ?? track.albumName, albumArtist: trackData['album_artist'] as String?, + artistId: trackData['artist_id']?.toString(), + albumId: trackData['album_id']?.toString(), coverUrl: coverUrl ?? track.coverUrl, isrc: trackData['isrc'] as String? ?? track.isrc, duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index afdbfdcf..604e558e 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -10,6 +10,7 @@ class DownloadRequestPayload { final String outputDir; final String filenameFormat; final String quality; + final bool embedMetadata; final bool embedLyrics; final bool embedMaxQualityCover; final int trackNumber; @@ -47,6 +48,7 @@ class DownloadRequestPayload { required this.outputDir, required this.filenameFormat, this.quality = 'LOSSLESS', + this.embedMetadata = true, this.embedLyrics = true, this.embedMaxQualityCover = true, this.trackNumber = 1, @@ -86,6 +88,7 @@ class DownloadRequestPayload { 'output_dir': outputDir, 'filename_format': filenameFormat, 'quality': quality, + 'embed_metadata': embedMetadata, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, 'track_number': trackNumber, @@ -129,6 +132,7 @@ class DownloadRequestPayload { outputDir: outputDir, filenameFormat: filenameFormat, quality: quality, + embedMetadata: embedMetadata, embedLyrics: embedLyrics, embedMaxQualityCover: embedMaxQualityCover, trackNumber: trackNumber, diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart index 9b613510..b7a20b44 100644 --- a/lib/services/downloaded_embedded_cover_resolver.dart +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver { LinkedHashMap(); static final Set _pendingExtract = {}; static final Set _pendingRefresh = {}; + static final Set _pendingPreviewValidation = {}; static final Set _failedExtract = {}; static String cleanFilePath(String? filePath) { @@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver { final cached = _cache[cleanPath]; if (cached != null) { - if (File(cached.previewPath).existsSync()) { - _touch(cleanPath, cached); - return cached.previewPath; - } - _cache.remove(cleanPath); - _cleanupTempCoverPathSync(cached.previewPath); + _touch(cleanPath, cached); + _validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged); + return cached.previewPath; } return null; @@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver { final cached = _cache.remove(cleanPath); _pendingExtract.remove(cleanPath); _pendingRefresh.remove(cleanPath); + _pendingPreviewValidation.remove(cleanPath); _failedExtract.remove(cleanPath); if (cached != null) { _cleanupTempCoverPathSync(cached.previewPath); @@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver { } _pendingExtract.remove(oldestKey); _pendingRefresh.remove(oldestKey); + _pendingPreviewValidation.remove(oldestKey); _failedExtract.remove(oldestKey); } } + static void _validateCachedPreviewAsync( + String cleanPath, + _EmbeddedCoverCacheEntry entry, { + VoidCallback? onChanged, + }) { + if (_pendingPreviewValidation.contains(cleanPath)) return; + _pendingPreviewValidation.add(cleanPath); + Future.microtask(() async { + try { + final exists = await fileExists(entry.previewPath); + if (!exists) { + final latest = _cache[cleanPath]; + if (latest != null && latest.previewPath == entry.previewPath) { + _cache.remove(cleanPath); + _failedExtract.remove(cleanPath); + onChanged?.call(); + } + _cleanupTempCoverPathSync(entry.previewPath); + } + } finally { + _pendingPreviewValidation.remove(cleanPath); + } + }); + } + static void _ensureCover( String cleanPath, { bool forceRefresh = false, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 81b511d2..b352162f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg'); class FFmpegService { static const int _commandLogPreviewLength = 300; + static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); + static const Duration _liveTunnelStartupPollInterval = Duration( + milliseconds: 200, + ); + static const Duration _liveTunnelStabilizationDelay = Duration( + milliseconds: 900, + ); static int _tempEmbedCounter = 0; + static FFmpegSession? _activeLiveDecryptSession; + static String? _activeLiveDecryptUrl; + static String? _activeLiveTempInputPath; + static String? _activeNativeDashManifestPath; + static String? _activeNativeDashManifestUrl; + static final Set _preparedNativeDashManifestPaths = {}; static String _buildOutputPath(String inputPath, String extension) { final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; @@ -305,6 +320,433 @@ class FFmpegService { return null; } + static bool isActiveLiveDecryptedUrl(String url) { + final active = _activeLiveDecryptUrl; + if (active == null || active.isEmpty) return false; + return active == url.trim(); + } + + static bool isActiveNativeDashManifestUrl(String url) { + final activeUrl = _activeNativeDashManifestUrl; + if (activeUrl == null || activeUrl.isEmpty) return false; + + final normalized = url.trim(); + if (activeUrl == normalized) return true; + + try { + final activePath = Uri.parse(activeUrl).toFilePath(); + final incomingPath = Uri.parse(normalized).toFilePath(); + return activePath == incomingPath; + } catch (_) { + return false; + } + } + + static Future prepareTidalDashManifestForNativePlayback({ + required String manifestPayload, + bool registerAsActive = true, + }) async { + final rawPayload = manifestPayload.trim(); + if (rawPayload.isEmpty) return null; + + final payload = rawPayload.startsWith('MANIFEST:') + ? rawPayload.substring('MANIFEST:'.length) + : rawPayload; + + final manifestPath = await _writeTempManifestFile(payload); + if (manifestPath == null) { + _log.e('Failed to prepare Tidal DASH manifest for native playback'); + return null; + } + + final manifestUrl = Uri.file(manifestPath).toString(); + _preparedNativeDashManifestPaths.add(manifestPath); + if (registerAsActive) { + await activatePreparedNativeDashManifest(manifestUrl); + } + return manifestUrl; + } + + static Future activatePreparedNativeDashManifest(String url) async { + final normalized = url.trim(); + if (normalized.isEmpty) return; + + final manifestPath = _nativeDashManifestPathFromUrl(normalized); + if (manifestPath == null || + !_preparedNativeDashManifestPaths.contains(manifestPath)) { + return; + } + + final previousPath = _activeNativeDashManifestPath; + _activeNativeDashManifestPath = manifestPath; + _activeNativeDashManifestUrl = Uri.file(manifestPath).toString(); + + if (previousPath != null && + previousPath.isNotEmpty && + previousPath != manifestPath) { + _preparedNativeDashManifestPaths.remove(previousPath); + await _deleteNativeDashManifestFile(previousPath); + } + } + + static Future stopNativeDashManifestPlayback() async { + final manifestPath = _activeNativeDashManifestPath; + _activeNativeDashManifestPath = null; + _activeNativeDashManifestUrl = null; + + if (manifestPath == null || manifestPath.isEmpty) return; + _preparedNativeDashManifestPaths.remove(manifestPath); + await _deleteNativeDashManifestFile(manifestPath); + } + + static Future cleanupInactivePreparedNativeDashManifests() async { + final activePath = _activeNativeDashManifestPath; + final stalePaths = _preparedNativeDashManifestPaths + .where((path) => path != activePath) + .toList(growable: false); + + for (final path in stalePaths) { + _preparedNativeDashManifestPaths.remove(path); + await _deleteNativeDashManifestFile(path); + } + } + + static String? _nativeDashManifestPathFromUrl(String url) { + try { + final uri = Uri.parse(url); + if (uri.scheme.toLowerCase() != 'file') { + return null; + } + final path = uri.toFilePath(); + return path.trim().isEmpty ? null : path; + } catch (_) { + return null; + } + } + + static Future _deleteNativeDashManifestFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + static Future stopLiveDecryptedStream() async { + final session = _activeLiveDecryptSession; + final tempInputPath = _activeLiveTempInputPath; + _activeLiveDecryptSession = null; + _activeLiveDecryptUrl = null; + _activeLiveTempInputPath = null; + + if (session != null) { + try { + await session.cancel(); + } catch (e) { + final sessionId = session.getSessionId(); + if (sessionId != null) { + try { + await FFmpegKit.cancel(sessionId); + } catch (_) {} + } + _log.w('Failed to stop live decrypt session cleanly: $e'); + } + } + + if (tempInputPath != null && tempInputPath.isNotEmpty) { + try { + final file = File(tempInputPath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + } + + static Future startTidalDashLiveStream({ + required String manifestPayload, + String preferredFormat = 'm4a', + }) async { + final rawPayload = manifestPayload.trim(); + if (rawPayload.isEmpty) return null; + + final payload = rawPayload.startsWith('MANIFEST:') + ? rawPayload.substring('MANIFEST:'.length) + : rawPayload; + + final manifestPath = await _writeTempManifestFile(payload); + if (manifestPath == null) { + _log.e('Failed to prepare Tidal DASH manifest for live stream'); + return null; + } + + await stopLiveDecryptedStream(); + await stopNativeDashManifestPlayback(); + + final attempts = _buildLiveDashFormatAttempts(preferredFormat); + for (final format in attempts) { + final stream = await _tryStartLiveDashAttempt( + manifestPath: manifestPath, + format: format, + ); + if (stream != null) { + _activeLiveDecryptSession = stream.session; + _activeLiveDecryptUrl = stream.localUrl; + _activeLiveTempInputPath = manifestPath; + return stream; + } + } + + try { + final file = File(manifestPath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + return null; + } + + static Future _writeTempManifestFile(String payload) async { + if (payload.trim().isEmpty) return null; + + Uint8List bytes; + try { + bytes = base64Decode(payload); + } catch (_) { + bytes = Uint8List.fromList(utf8.encode(payload)); + } + + final manifestText = utf8.decode(bytes, allowMalformed: true).trim(); + if (manifestText.isEmpty) return null; + + final tempDir = await getTemporaryDirectory(); + final manifestPath = + '${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd'; + await File(manifestPath).writeAsString(manifestText, flush: true); + return manifestPath; + } + + static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts( + String preferredFormat, + ) { + final normalized = preferredFormat.trim().toLowerCase(); + if (normalized == 'flac') { + return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; + } + return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; + } + + static Future _awaitLiveTunnelReady(FFmpegSession session) async { + final deadline = DateTime.now().add(_liveTunnelStartupTimeout); + var seenRunning = false; + + while (DateTime.now().isBefore(deadline)) { + final state = await session.getState(); + if (state == SessionState.running) { + seenRunning = true; + break; + } + if (state != SessionState.created) { + return false; + } + await Future.delayed(_liveTunnelStartupPollInterval); + } + + if (!seenRunning) { + return false; + } + + await Future.delayed(_liveTunnelStabilizationDelay); + return (await session.getState()) == SessionState.running; + } + + static Future _tryStartLiveDashAttempt({ + required String manifestPath, + required _LiveDecryptFormat format, + }) async { + final port = await _allocateLoopbackPort(); + final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; + final mimeType = format == _LiveDecryptFormat.flac + ? 'audio/flac' + : 'audio/mp4'; + final localUrl = 'http://localhost:$port/stream.$ext'; + + final commandArguments = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-protocol_whitelist', + 'file,http,https,tcp,tls,crypto,data', + '-i', + manifestPath, + '-map', + '0:a:0', + '-c:a', + 'copy', + if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], + if (format == _LiveDecryptFormat.m4a) ...[ + '-movflags', + '+frag_keyframe+empty_moov+default_base_moof', + '-f', + 'mp4', + ], + '-content_type', + mimeType, + '-listen', + '1', + localUrl, + ]; + + _log.d( + 'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', + ); + + final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); + final isReady = await _awaitLiveTunnelReady(session); + if (isReady) { + return LiveDecryptedStreamResult( + localUrl: localUrl, + format: ext, + session: session, + ); + } + + final state = await session.getState(); + final output = (await session.getOutput() ?? '').trim(); + if (output.isNotEmpty) { + _log.w('Tidal DASH tunnel failed ($ext): $output'); + } else { + _log.w('Tidal DASH tunnel failed ($ext) with session state: $state'); + } + + try { + await session.cancel(); + } catch (_) {} + return null; + } + + static Future startAmazonLiveDecryptedStream({ + required String encryptedStreamUrl, + required String decryptionKey, + String preferredFormat = 'flac', + }) async { + final inputUrl = encryptedStreamUrl.trim(); + if (inputUrl.isEmpty) return null; + + final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey); + if (keyCandidates.isEmpty) { + _log.e('No usable decryption key candidates for live stream'); + return null; + } + + await stopLiveDecryptedStream(); + + final attempts = _buildLiveDecryptFormatAttempts(preferredFormat); + for (final format in attempts) { + for (final keyCandidate in keyCandidates) { + final stream = await _tryStartLiveDecryptAttempt( + inputUrl: inputUrl, + decryptionKey: keyCandidate, + format: format, + ); + if (stream != null) { + _activeLiveDecryptSession = stream.session; + _activeLiveDecryptUrl = stream.localUrl; + _activeLiveTempInputPath = null; + return stream; + } + } + } + + return null; + } + + static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts( + String preferredFormat, + ) { + final normalized = preferredFormat.trim().toLowerCase(); + if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') { + return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; + } + return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; + } + + static Future _tryStartLiveDecryptAttempt({ + required String inputUrl, + required String decryptionKey, + required _LiveDecryptFormat format, + }) async { + final port = await _allocateLoopbackPort(); + final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; + final mimeType = format == _LiveDecryptFormat.flac + ? 'audio/flac' + : 'audio/mp4'; + final localUrl = 'http://localhost:$port/stream.$ext'; + + final commandArguments = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-decryption_key', + decryptionKey, + '-i', + inputUrl, + '-map', + '0:a:0', + '-c:a', + 'copy', + if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], + if (format == _LiveDecryptFormat.m4a) ...[ + '-movflags', + '+frag_keyframe+empty_moov+default_base_moof', + '-f', + 'mp4', + ], + '-content_type', + mimeType, + '-listen', + '1', + localUrl, + ]; + + _log.d( + 'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', + ); + + final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); + final isReady = await _awaitLiveTunnelReady(session); + if (isReady) { + return LiveDecryptedStreamResult( + localUrl: localUrl, + format: ext, + session: session, + ); + } + + final state = await session.getState(); + final output = (await session.getOutput() ?? '').trim(); + if (output.isNotEmpty) { + _log.w('Live decrypt attempt failed ($ext): $output'); + } else { + _log.w('Live decrypt attempt failed ($ext) with session state: $state'); + } + + try { + await session.cancel(); + } catch (_) {} + return null; + } + + static Future _allocateLoopbackPort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; + } + static Future convertFlacToOpus( String inputPath, { String bitrate = '128k', @@ -861,9 +1303,10 @@ class FFmpegService { for (final entry in vorbisMetadata.entries) { final key = entry.key.toUpperCase(); + final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; - switch (key) { + switch (normalizedKey) { case 'TITLE': id3Map['title'] = value; break; @@ -878,10 +1321,12 @@ class FFmpegService { break; case 'TRACKNUMBER': case 'TRACK': + case 'TRCK': id3Map['track'] = value; break; case 'DISCNUMBER': case 'DISC': + case 'TPOS': id3Map['disc'] = value; break; case 'DATE': @@ -921,3 +1366,17 @@ class FFmpegResult { required this.output, }); } + +enum _LiveDecryptFormat { flac, m4a } + +class LiveDecryptedStreamResult { + final String localUrl; + final String format; + final FFmpegSession session; + + LiveDecryptedStreamResult({ + required this.localUrl, + required this.format, + required this.session, + }); +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6e121f4c..6710df4b 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge'); class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + static const _downloadProgressEvents = EventChannel( + 'com.zarz.spotiflac/download_progress_stream', + ); + static const _libraryScanProgressEvents = EventChannel( + 'com.zarz.spotiflac/library_scan_progress_stream', + ); static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); @@ -48,6 +54,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getSpotifyRelatedArtists( + String artistId, { + int limit = 12, + }) async { + final result = await _channel.invokeMethod('getSpotifyRelatedArtists', { + 'artist_id': artistId, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + static Future> checkAvailability( String spotifyId, String isrc, @@ -113,6 +130,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Stream> downloadProgressStream() { + return _downloadProgressEvents.receiveBroadcastStream().map((event) { + if (event is String) { + return jsonDecode(event) as Map; + } + if (event is Map) { + return Map.from(event); + } + return const {}; + }); + } + static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } @@ -532,6 +561,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getDeezerRelatedArtists( + String artistId, { + int limit = 12, + }) async { + final result = await _channel.invokeMethod('getDeezerRelatedArtists', { + 'artist_id': artistId, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + static Future> getDeezerMetadata( String resourceType, String resourceId, @@ -1098,6 +1138,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Stream> libraryScanProgressStream() { + return _libraryScanProgressEvents.receiveBroadcastStream().map((event) { + if (event is String) { + return jsonDecode(event) as Map; + } + if (event is Map) { + return Map.from(event); + } + return const {}; + }); + } + /// Cancel ongoing library scan static Future cancelLibraryScan() async { await _channel.invokeMethod('cancelLibraryScan'); diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart new file mode 100644 index 00000000..614628c4 --- /dev/null +++ b/lib/services/shell_navigation_service.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +class ShellNavigationService { + static final GlobalKey homeTabNavigatorKey = + GlobalKey(); + static final GlobalKey libraryTabNavigatorKey = + GlobalKey(); + static final GlobalKey storeTabNavigatorKey = + GlobalKey(); + + static int _currentTabIndex = 0; + static bool _showStoreTab = false; + + static void syncState({ + required int currentTabIndex, + required bool showStoreTab, + }) { + _currentTabIndex = currentTabIndex; + _showStoreTab = showStoreTab; + } + + static NavigatorState? activeTabNavigator() { + if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; + if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; + if (_showStoreTab && _currentTabIndex == 2) { + return storeTabNavigatorKey.currentState; + } + return null; + } +} diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 1f791496..3a2fb50f 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -28,36 +27,6 @@ class UpdateChecker { static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; - static Future _getDeviceArch() async { - if (!Platform.isAndroid) return 'unknown'; - - try { - final cpuInfo = await File('/proc/cpuinfo').readAsString(); - - if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) { - return 'arm64'; - } - - final result = await Process.run('uname', ['-m']); - final arch = result.stdout.toString().trim().toLowerCase(); - - if (arch.contains('aarch64') || arch.contains('arm64')) { - return 'arm64'; - } else if (arch.contains('armv7') || arch.contains('arm')) { - return 'arm32'; - } else if (arch.contains('x86_64')) { - return 'x86_64'; - } else if (arch.contains('x86') || arch.contains('i686')) { - return 'x86'; - } - - return 'arm64'; - } catch (e) { - _log.e('Error detecting arch: $e'); - return 'arm64'; - } - } - /// Check for updates based on channel preference /// [channel] can be 'stable' or 'preview' static Future checkForUpdate({String channel = 'stable'}) async { @@ -109,11 +78,7 @@ class UpdateChecker { final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); - final deviceArch = await _getDeviceArch(); - _log.d('Device architecture: $deviceArch'); - String? arm64Url; - String? arm32Url; String? universalUrl; final assets = releaseData['assets'] as List? ?? []; @@ -128,22 +93,14 @@ class UpdateChecker { } if (name.contains('arm64') || name.contains('v8a')) { arm64Url = downloadUrl; - } else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) { - arm32Url = downloadUrl; } else if (name.contains('universal')) { universalUrl = downloadUrl; } } } - String? apkUrl; - if (deviceArch == 'arm64') { - apkUrl = arm64Url ?? universalUrl ?? arm32Url; - } else if (deviceArch == 'arm32') { - apkUrl = arm32Url ?? universalUrl; - } else { - apkUrl = universalUrl ?? arm64Url ?? arm32Url; - } + // Only arm64 is supported; fall back to universal if available + final apkUrl = arm64Url ?? universalUrl; _log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart new file mode 100644 index 00000000..697a9a89 --- /dev/null +++ b/lib/utils/artist_utils.dart @@ -0,0 +1,15 @@ +final RegExp _artistNameSplitPattern = RegExp( + r'\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*', + caseSensitive: false, +); + +List splitArtistNames(String rawArtists) { + final raw = rawArtists.trim(); + if (raw.isEmpty) return const []; + + return raw + .split(_artistNameSplitPattern) + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(growable: false); +} diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart new file mode 100644 index 00000000..e7f6d8d7 --- /dev/null +++ b/lib/utils/clickable_metadata.dart @@ -0,0 +1,577 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/album_screen.dart'; +import 'package:spotiflac_android/screens/home_tab.dart' + show ExtensionArtistScreen, ExtensionAlbumScreen; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('ClickableMetadata'); + +/// Navigate to an artist screen by searching Deezer for the artist ID. +/// +/// If [artistId] is provided and valid, navigates directly. +/// Otherwise, searches Deezer by [artistName] to resolve the ID first. +/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen. +Future navigateToArtist( + BuildContext context, { + required String artistName, + String? artistId, + String? coverUrl, + String? extensionId, +}) async { + if (artistName.isEmpty) return; + + final normalizedArtistId = _normalizeArtistId(artistId); + + // If we have a valid artist ID already, navigate directly + if (normalizedArtistId != null && + _canNavigateArtistDirectly( + artistId: normalizedArtistId, + extensionId: extensionId, + )) { + _pushArtistScreen( + context, + artistId: normalizedArtistId, + artistName: artistName, + coverUrl: coverUrl, + extensionId: extensionId, + ); + return; + } + + // Search Deezer to resolve the artist ID + _showLoadingSnackBar(context, 'Looking up artist...'); + try { + final results = await PlatformBridge.searchDeezerAll( + artistName, + trackLimit: 0, + artistLimit: 3, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final artistList = results['artists'] as List? ?? []; + if (artistList.isEmpty) { + _showUnavailable(context, 'Artist'); + return; + } + + // Find best match - prefer exact name match (case-insensitive) + Map? bestMatch; + final lowerName = artistName.toLowerCase().trim(); + for (final a in artistList) { + if (a is Map) { + final name = (a['name'] as String? ?? '').toLowerCase().trim(); + if (name == lowerName) { + bestMatch = a; + break; + } + } + } + bestMatch ??= artistList.first as Map; + + final resolvedId = bestMatch['id'] as String? ?? ''; + final resolvedName = bestMatch['name'] as String? ?? artistName; + final resolvedImage = bestMatch['images'] as String?; + + if (resolvedId.isEmpty) { + _showUnavailable(context, 'Artist'); + return; + } + + if (!context.mounted) return; + _pushArtistScreen( + context, + artistId: resolvedId, + artistName: resolvedName, + coverUrl: resolvedImage ?? coverUrl, + ); + } catch (e) { + _log.e('Failed to look up artist "$artistName": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, 'Artist'); + } +} + +/// Navigate to an album screen by searching Deezer for the album ID. +/// +/// If [albumId] is provided and valid, navigates directly. +/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID. +/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen. +Future navigateToAlbum( + BuildContext context, { + required String albumName, + String? albumId, + String? artistName, + String? coverUrl, + String? extensionId, +}) async { + if (albumName.isEmpty) return; + + // If we have a valid album ID already, navigate directly + if (albumId != null && + albumId.isNotEmpty && + albumId != 'unknown' && + albumId != 'deezer:unknown') { + _pushAlbumScreen( + context, + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + extensionId: extensionId, + ); + return; + } + + // If it's extension-based content without an ID, can't search Deezer for it + if (extensionId != null) { + _showUnavailable(context, 'Album'); + return; + } + + // Search Deezer to resolve the album ID + _showLoadingSnackBar(context, 'Looking up album...'); + try { + // Build search query: "albumName artistName" for better accuracy + final query = artistName != null && artistName.isNotEmpty + ? '$albumName $artistName' + : albumName; + + final results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 0, + artistLimit: 0, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final albumList = results['albums'] as List? ?? []; + if (albumList.isEmpty) { + _showUnavailable(context, 'Album'); + return; + } + + // Find best match - prefer exact name match (case-insensitive) + Map? bestMatch; + final lowerName = albumName.toLowerCase().trim(); + for (final a in albumList) { + if (a is Map) { + final name = (a['name'] as String? ?? '').toLowerCase().trim(); + if (name == lowerName) { + bestMatch = a; + break; + } + } + } + bestMatch ??= albumList.first as Map; + + final resolvedId = bestMatch['id'] as String? ?? ''; + final resolvedName = bestMatch['name'] as String? ?? albumName; + final resolvedImage = bestMatch['images'] as String?; + + if (resolvedId.isEmpty) { + _showUnavailable(context, 'Album'); + return; + } + + if (!context.mounted) return; + _pushAlbumScreen( + context, + albumId: resolvedId, + albumName: resolvedName, + coverUrl: resolvedImage ?? coverUrl, + ); + } catch (e) { + _log.e('Failed to look up album "$albumName": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, 'Album'); + } +} + +void _pushArtistScreen( + BuildContext context, { + required String artistId, + required String artistName, + String? coverUrl, + String? extensionId, +}) { + _pushViaPreferredNavigator( + context, + (context) => extensionId != null + ? ExtensionArtistScreen( + extensionId: extensionId, + artistId: artistId, + artistName: artistName, + coverUrl: coverUrl, + ) + : ArtistScreen( + artistId: artistId, + artistName: artistName, + coverUrl: coverUrl, + ), + ); +} + +void _pushAlbumScreen( + BuildContext context, { + required String albumId, + required String albumName, + String? coverUrl, + String? extensionId, +}) { + _pushViaPreferredNavigator( + context, + (context) => extensionId != null + ? ExtensionAlbumScreen( + extensionId: extensionId, + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + ) + : AlbumScreen( + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + tracks: const [], + ), + ); +} + +void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { + final currentNavigator = Navigator.of(context); + final rootNavigator = Navigator.of(context, rootNavigator: true); + final activeTabNavigator = ShellNavigationService.activeTabNavigator(); + + final shouldRouteToTabNavigator = + identical(currentNavigator, rootNavigator) && activeTabNavigator != null; + + if (!shouldRouteToTabNavigator) { + currentNavigator.push(MaterialPageRoute(builder: builder)); + return; + } + + final currentRoute = ModalRoute.of(context); + final shouldPopCurrentRoute = + currentRoute != null && currentRoute.isFirst == false; + + if (shouldPopCurrentRoute && currentNavigator.canPop()) { + currentNavigator.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!activeTabNavigator.mounted) return; + activeTabNavigator.push(MaterialPageRoute(builder: builder)); + }); + return; + } + + activeTabNavigator.push(MaterialPageRoute(builder: builder)); +} + +void _showLoadingSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(message), + ], + ), + duration: const Duration(seconds: 10), + ), + ); +} + +void _showUnavailable(BuildContext context, String type) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$type information not available'))); +} + +/// A reusable widget that makes text tappable to navigate to an artist screen. +/// +/// Wraps the text in a GestureDetector that, when tapped, looks up the artist +/// via Deezer search and navigates to the ArtistScreen. +class ClickableArtistName extends StatefulWidget { + final String artistName; + final String? artistId; + final String? coverUrl; + final String? extensionId; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + const ClickableArtistName({ + super.key, + required this.artistName, + this.artistId, + this.coverUrl, + this.extensionId, + this.style, + this.maxLines, + this.overflow, + this.textAlign, + }); + + @override + State createState() => _ClickableArtistNameState(); +} + +class _ClickableArtistNameState extends State { + List<_ArtistTapTarget> _artistTargets = const []; + final List _recognizers = []; + + @override + void initState() { + super.initState(); + _rebuildArtistTargets(); + } + + @override + void didUpdateWidget(covariant ClickableArtistName oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.artistName != widget.artistName || + oldWidget.artistId != widget.artistId || + oldWidget.coverUrl != widget.coverUrl || + oldWidget.extensionId != widget.extensionId) { + _rebuildArtistTargets(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _rebuildArtistTargets() { + _disposeRecognizers(); + _artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId); + if (_artistTargets.length <= 1) return; + + for (final target in _artistTargets) { + final recognizer = TapGestureRecognizer() + ..onTap = () => navigateToArtist( + context, + artistName: target.name, + artistId: target.artistId, + coverUrl: widget.coverUrl, + extensionId: _extensionIdForTarget(target), + ); + _recognizers.add(recognizer); + } + } + + String? _extensionIdForTarget(_ArtistTapTarget target) { + if (widget.extensionId == null) return null; + if (_artistTargets.length == 1) return widget.extensionId; + return target.artistId != null ? widget.extensionId : null; + } + + List _buildMultiArtistSpans() { + final spans = []; + for (var i = 0; i < _artistTargets.length; i++) { + final target = _artistTargets[i]; + spans.add( + TextSpan( + text: target.name, + style: widget.style, + recognizer: _recognizers[i], + ), + ); + if (i < _artistTargets.length - 1) { + spans.add(TextSpan(text: ', ', style: widget.style)); + } + } + return spans; + } + + @override + Widget build(BuildContext context) { + if (_artistTargets.isEmpty) { + return Text( + widget.artistName, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + textAlign: widget.textAlign, + ); + } + + if (_artistTargets.length == 1) { + final target = _artistTargets.first; + return GestureDetector( + onTap: () => navigateToArtist( + context, + artistName: target.name, + artistId: target.artistId, + coverUrl: widget.coverUrl, + extensionId: _extensionIdForTarget(target), + ), + child: Text( + target.name, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + textAlign: widget.textAlign, + ), + ); + } + + return Text.rich( + TextSpan(style: widget.style, children: _buildMultiArtistSpans()), + maxLines: widget.maxLines, + overflow: widget.overflow ?? TextOverflow.clip, + textAlign: widget.textAlign ?? TextAlign.start, + ); + } +} + +class _ArtistTapTarget { + final String name; + final String? artistId; + + const _ArtistTapTarget({required this.name, this.artistId}); +} + +List<_ArtistTapTarget> _buildArtistTapTargets( + String rawArtistNames, + String? rawArtistIds, +) { + final parsedNames = splitArtistNames(rawArtistNames); + if (parsedNames.isEmpty) return const []; + + final uniqueNames = []; + final seen = {}; + for (final parsed in parsedNames) { + final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim(); + if (key.isEmpty || !seen.add(key)) continue; + uniqueNames.add(parsed); + } + if (uniqueNames.isEmpty) return const []; + + if (uniqueNames.length == 1) { + return [ + _ArtistTapTarget( + name: uniqueNames.first, + artistId: _normalizeArtistId(rawArtistIds), + ), + ]; + } + + final parsedIds = _parseArtistIds(rawArtistIds); + if (parsedIds.length == uniqueNames.length) { + return List<_ArtistTapTarget>.generate( + uniqueNames.length, + (index) => _ArtistTapTarget( + name: uniqueNames[index], + artistId: parsedIds[index], + ), + growable: false, + ); + } + + return uniqueNames + .map((name) => _ArtistTapTarget(name: name)) + .toList(growable: false); +} + +List _parseArtistIds(String? rawArtistIds) { + final raw = rawArtistIds?.trim(); + if (raw == null || raw.isEmpty) return const []; + + final parsed = []; + for (final part in raw.split(RegExp(r'\s*,\s*'))) { + final normalized = _normalizeArtistId(part); + if (normalized != null) { + parsed.add(normalized); + } + } + return parsed; +} + +String? _normalizeArtistId(String? artistId) { + final id = artistId?.trim(); + if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') { + return null; + } + return id; +} + +bool _canNavigateArtistDirectly({ + required String artistId, + required String? extensionId, +}) { + if (extensionId != null) return true; + if (artistId.startsWith('deezer:')) return true; + return _spotifyArtistIdPattern.hasMatch(artistId); +} + +final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + +/// A reusable widget that makes text tappable to navigate to an album screen. +/// +/// Wraps the text in a GestureDetector that, when tapped, looks up the album +/// via Deezer search and navigates to the AlbumScreen. +class ClickableAlbumName extends StatelessWidget { + final String albumName; + final String? albumId; + final String? artistName; + final String? coverUrl; + final String? extensionId; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + const ClickableAlbumName({ + super.key, + required this.albumName, + this.albumId, + this.artistName, + this.coverUrl, + this.extensionId, + this.style, + this.maxLines, + this.overflow, + this.textAlign, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => navigateToAlbum( + context, + albumName: albumName, + albumId: albumId, + artistName: artistName, + coverUrl: coverUrl, + extensionId: extensionId, + ), + child: Text( + albumName, + style: style, + maxLines: maxLines, + overflow: overflow, + textAlign: textAlign, + ), + ); + } +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 0396cb25..21b05b5e 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -22,7 +22,7 @@ class BuiltInService { }); } -/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube) +/// Default quality options for built-in services /// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads const _builtInServices = [ BuiltInService( @@ -129,6 +129,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart new file mode 100644 index 00000000..3a31bd2d --- /dev/null +++ b/lib/widgets/mini_player_bar.dart @@ -0,0 +1,2040 @@ +import 'dart:io'; +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/playback_item.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart' + as playback_types + show RepeatMode; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; + +const Set _builtInPlaybackSources = { + 'deezer', + 'spotify', + 'tidal', + 'qobuz', + 'amazon', + 'youtube', + 'ytmusic', + 'local', +}; + +String? _playbackItemExtensionId(PlaybackItem item) { + final source = (item.track?.source ?? '').trim(); + if (source.isEmpty) return null; + if (_builtInPlaybackSources.contains(source.toLowerCase())) return null; + return source; +} + +// ─── Mini Player Bar ───────────────────────────────────────────────────────── +class MiniPlayerBar extends ConsumerWidget { + const MiniPlayerBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stateSnapshot = ref.watch( + playbackProvider.select( + (s) => ( + currentItem: s.currentItem, + isPlaying: s.isPlaying, + isBuffering: s.isBuffering, + isLoading: s.isLoading, + hasNext: s.hasNext, + repeatMode: s.repeatMode, + error: s.error, + errorType: s.errorType, + ), + ), + ); + final playbackError = _localizedPlaybackErrorFromRaw( + context, + stateSnapshot.error, + stateSnapshot.errorType, + ); + final item = stateSnapshot.currentItem; + if (item == null) return const SizedBox.shrink(); + + final colorScheme = Theme.of(context).colorScheme; + + return Material( + color: colorScheme.surfaceContainerHighest, + child: InkWell( + onTap: () => _showExpandedPlayer(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _MiniPlayerProgressBar(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Cover art + _CoverArt( + url: item.coverUrl, + isLocal: item.hasLocalCover, + size: 40, + borderRadius: 8, + ), + const SizedBox(width: 10), + // Track info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + item.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + // Error indicator + if (playbackError != null) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + Icons.error_outline_rounded, + size: 20, + color: colorScheme.error, + ), + ), + // Loading indicator + if (stateSnapshot.isBuffering || stateSnapshot.isLoading) + const Padding( + padding: EdgeInsets.only(right: 8), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + // Play / Pause + IconButton( + icon: Icon( + stateSnapshot.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + onPressed: () => + ref.read(playbackProvider.notifier).togglePlayPause(), + ), + // Next + if (stateSnapshot.hasNext || + stateSnapshot.repeatMode == playback_types.RepeatMode.all) + IconButton( + icon: const Icon(Icons.skip_next_rounded, size: 22), + onPressed: () => + ref.read(playbackProvider.notifier).skipNext(), + ), + // Close + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () => + ref.read(playbackProvider.notifier).dismissPlayer(), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _showExpandedPlayer(BuildContext context) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (context, animation, secondaryAnimation) => + const _FullScreenPlayer(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 350), + reverseTransitionDuration: const Duration(milliseconds: 300), + ), + ); + } +} + +class _MiniPlayerProgressBar extends ConsumerWidget { + const _MiniPlayerProgressBar(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progressState = ref.watch( + playbackProvider.select( + (s) => (position: s.position, duration: s.duration), + ), + ); + final colorScheme = Theme.of(context).colorScheme; + final durationMs = progressState.duration.inMilliseconds; + final positionMs = progressState.position.inMilliseconds.clamp( + 0, + durationMs > 0 ? durationMs : 0, + ); + final progress = durationMs > 0 ? positionMs / durationMs : 0.0; + + return LinearProgressIndicator( + value: progress, + minHeight: 2, + backgroundColor: colorScheme.surfaceContainerHighest, + ); + } +} + +// ─── Full-Screen Player ────────────────────────────────────────────────────── +class _FullScreenPlayer extends ConsumerStatefulWidget { + const _FullScreenPlayer(); + + @override + ConsumerState<_FullScreenPlayer> createState() => _FullScreenPlayerState(); +} + +class _FullScreenPlayerState extends ConsumerState<_FullScreenPlayer> { + // 0 = cover art view, 1 = lyrics view + int _currentPage = 0; + late final PageController _pageController; + bool _isScrubbing = false; + double _scrubSeconds = 0; + double _topBarDragOffset = 0; + String? _lastLyricsPrefetchKey; + AppLifecycleListener? _appLifecycleListener; + bool _isAppResumed = true; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + final initialState = WidgetsBinding.instance.lifecycleState; + _isAppResumed = + initialState == null || initialState == AppLifecycleState.resumed; + _appLifecycleListener = AppLifecycleListener( + onResume: () { + _isAppResumed = true; + if (!mounted) return; + final state = ref.read(playbackProvider); + _prefetchLyricsForCurrentTrack(state); + }, + onPause: () => _isAppResumed = false, + onHide: () => _isAppResumed = false, + onDetach: () => _isAppResumed = false, + onInactive: () => _isAppResumed = false, + ); + } + + @override + void dispose() { + _appLifecycleListener?.dispose(); + _appLifecycleListener = null; + _pageController.dispose(); + super.dispose(); + } + + String _lyricsPrefetchKey(PlaybackItem item) { + return '${item.id}|${item.title}|${item.artist}'; + } + + void _prefetchLyricsForCurrentTrack(PlaybackState state) { + if (!_isAppResumed) return; + final item = state.currentItem; + if (item == null) return; + + final key = _lyricsPrefetchKey(item); + if (_lastLyricsPrefetchKey == key) return; + _lastLyricsPrefetchKey = key; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(playbackProvider.notifier).ensureLyricsLoaded()); + }); + } + + void _switchToLyrics() { + setState(() => _currentPage = 1); + _pageController.animateToPage( + 1, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } + + void _switchToCover() { + setState(() => _currentPage = 0); + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } + + void _handleTopBarDragUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta ?? 0; + if (delta <= 0) return; + _topBarDragOffset += delta; + } + + void _handleTopBarDragEnd(DragEndDetails details) { + final swipeVelocity = details.primaryVelocity ?? 0; + final shouldDismiss = _topBarDragOffset > 72 || swipeVelocity > 900; + _topBarDragOffset = 0; + if (!shouldDismiss) return; + if (!mounted) return; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(playbackProvider); + final playbackNotifier = ref.read(playbackProvider.notifier); + final displayOrder = playbackNotifier.getQueueDisplayOrder(); + final displayPosition = playbackNotifier.getCurrentDisplayQueuePosition( + displayOrder: displayOrder, + ); + final queuePositionLabel = displayPosition >= 0 + ? displayPosition + 1 + : state.currentIndex + 1; + final playbackError = _localizedPlaybackError(context, state); + final item = state.currentItem; + if (item == null) { + _lastLyricsPrefetchKey = null; + // Track stopped, close the player + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).pop(); + }); + return const SizedBox.shrink(); + } + _prefetchLyricsForCurrentTrack(state); + final extensionId = _playbackItemExtensionId(item); + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final screenSize = MediaQuery.sizeOf(context); + final isLandscape = screenSize.width > screenSize.height; + + final duration = state.duration; + final position = state.position; + final maxSeconds = duration.inMilliseconds > 0 + ? duration.inSeconds.toDouble() + : 0.0; + final currentSeconds = position.inSeconds.toDouble().clamp( + 0.0, + maxSeconds > 0 ? maxSeconds : 0.0, + ); + final sliderSeconds = _isScrubbing + ? _scrubSeconds.clamp(0.0, maxSeconds > 0 ? maxSeconds : 0.0) + : currentSeconds; + + return Scaffold( + backgroundColor: colorScheme.surface, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isCompactLayout = isLandscape || constraints.maxHeight < 620; + final mediaSectionHeight = + (constraints.maxHeight * (isCompactLayout ? 0.32 : 0.50)).clamp( + isCompactLayout ? 140.0 : 260.0, + isCompactLayout ? 280.0 : 560.0, + ); + final horizontalPadding = isCompactLayout ? 16.0 : 24.0; + final verticalGap = isCompactLayout ? 2.0 : 4.0; + final showAlbum = item.album.isNotEmpty && !isCompactLayout; + + return SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + children: [ + // ── Top bar (close + title + lyrics toggle) + GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _handleTopBarDragUpdate, + onVerticalDragEnd: _handleTopBarDragEnd, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: isCompactLayout ? 2 : 4, + ), + child: Row( + children: [ + // ── Left side + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: IconButton( + icon: const Icon( + Icons.keyboard_arrow_down_rounded, + size: 30, + ), + visualDensity: isCompactLayout + ? VisualDensity.compact + : VisualDensity.standard, + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Close', + ), + ), + ), + // ── Center: Queue info + if (state.queue.length > 1) + GestureDetector( + onTap: () => _showQueueSheet(context, ref), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.queue_music_rounded, + size: 16, + color: colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 6), + Text( + '$queuePositionLabel / ${state.queue.length}', + style: textTheme.labelMedium?.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + // ── Right side + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (!item.isLocal && item.track != null) + _DownloadButton( + item: item, + compact: isCompactLayout, + ), + IconButton( + visualDensity: isCompactLayout + ? VisualDensity.compact + : VisualDensity.standard, + icon: Icon( + Icons.lyrics_outlined, + color: _currentPage == 1 + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + onPressed: () { + if (_currentPage == 0) { + _switchToLyrics(); + } else { + _switchToCover(); + } + }, + tooltip: 'Lyrics', + ), + ], + ), + ), + ], + ), + ), + ), + + // ── Main content area (swipeable cover / lyrics) + SizedBox( + height: mediaSectionHeight, + child: PageView( + controller: _pageController, + onPageChanged: (page) => + setState(() => _currentPage = page), + children: [ + // Page 0: Cover art + _CoverArtPage(item: item, colorScheme: colorScheme), + // Page 1: Lyrics + _LyricsPage( + state: state, + colorScheme: colorScheme, + onRetry: () => ref + .read(playbackProvider.notifier) + .refetchLyrics(), + onSeek: state.seekSupported + ? (ms) => ref + .read(playbackProvider.notifier) + .seek(Duration(milliseconds: ms)) + : null, + ), + ], + ), + ), + + // ── Page indicator dots + Padding( + padding: EdgeInsets.only(top: isCompactLayout ? 4 : 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _PageDot( + active: _currentPage == 0, + colorScheme: colorScheme, + ), + const SizedBox(width: 6), + _PageDot( + active: _currentPage == 1, + colorScheme: colorScheme, + ), + ], + ), + ), + SizedBox(height: isCompactLayout ? 4 : 8), + + // ── Track info + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Column( + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: + (isCompactLayout + ? textTheme.titleMedium + : textTheme.titleLarge) + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: verticalGap), + ClickableArtistName( + artistName: item.artist, + artistId: item.track?.artistId, + extensionId: extensionId, + coverUrl: item.coverUrl.isNotEmpty + ? item.coverUrl + : null, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + (isCompactLayout + ? textTheme.bodySmall + : textTheme.bodyMedium) + ?.copyWith( + color: colorScheme.primary, + ), + ), + if (showAlbum) ...[ + const SizedBox(height: 2), + ClickableAlbumName( + albumName: item.album, + albumId: item.track?.albumId, + artistName: item.artist, + extensionId: extensionId, + coverUrl: item.coverUrl.isNotEmpty + ? item.coverUrl + : null, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), + ), + ], + ], + ), + ), + SizedBox( + width: 48, + child: item.track != null + ? Consumer( + builder: (context, ref, child) { + final isLoved = ref.watch( + libraryCollectionsProvider.select( + (s) => s.isLoved(item.track!), + ), + ); + return IconButton( + icon: Icon( + isLoved + ? Icons.favorite + : Icons.favorite_border, + size: isCompactLayout ? 24 : 28, + ), + color: isLoved + ? Colors.redAccent + : colorScheme.onSurfaceVariant, + onPressed: () => ref + .read( + libraryCollectionsProvider + .notifier, + ) + .toggleLoved(item.track!), + ); + }, + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + SizedBox(height: verticalGap), + + // ── Quality + Service badge row + _QualityServiceRow(item: item, colorScheme: colorScheme), + SizedBox(height: verticalGap), + + // ── Error message + if (playbackError != null) + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalGap, + ), + child: Text( + playbackError, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ), + + // ── Seek slider + Padding( + padding: EdgeInsets.symmetric( + horizontal: isCompactLayout ? 12 : 16, + ), + child: SliderTheme( + data: SliderThemeData( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 14, + ), + activeTrackColor: colorScheme.primary, + inactiveTrackColor: colorScheme.primary.withValues( + alpha: 0.15, + ), + ), + child: Slider( + value: sliderSeconds, + max: maxSeconds > 0 ? maxSeconds : 1, + onChangeStart: state.seekSupported && maxSeconds > 0 + ? (value) { + setState(() { + _isScrubbing = true; + _scrubSeconds = value; + }); + } + : null, + onChanged: state.seekSupported + ? (value) { + if (!_isScrubbing) { + setState(() { + _isScrubbing = true; + }); + } + setState(() { + _scrubSeconds = value; + }); + } + : null, + onChangeEnd: state.seekSupported + ? (value) async { + setState(() { + _scrubSeconds = value; + _isScrubbing = false; + }); + await ref + .read(playbackProvider.notifier) + .seek( + Duration( + milliseconds: (value * 1000).round(), + ), + ); + } + : null, + ), + ), + ), + + // ── Duration labels + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + _formatDuration(duration), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + SizedBox(height: verticalGap), + + // ── Playback controls + _PlaybackControls(state: state, compact: isCompactLayout), + SizedBox(height: verticalGap), + ], + ), + ), + ); + }, + ), + ), + ); + } + + String _formatDuration(Duration duration) { + final totalSeconds = duration.inSeconds; + final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + void _showQueueSheet(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _QueueBottomSheet(ref: ref), + ); + } +} + +String? _localizedPlaybackError(BuildContext context, PlaybackState state) { + return _localizedPlaybackErrorFromRaw(context, state.error, state.errorType); +} + +String? _localizedPlaybackErrorFromRaw( + BuildContext context, + String? error, + String? errorType, +) { + final raw = (error ?? '').trim(); + if (raw.isEmpty) { + return null; + } + if (errorType == 'seek_not_supported') { + return context.l10n.errorSeekNotSupported; + } + if (errorType == 'not_found') { + return context.l10n.errorNoTracksFound; + } + return raw; +} + +// ─── Page dot indicator ────────────────────────────────────────────────────── +class _PageDot extends StatelessWidget { + final bool active; + final ColorScheme colorScheme; + + const _PageDot({required this.active, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: active ? 16 : 6, + height: 6, + decoration: BoxDecoration( + color: active ? colorScheme.primary : colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(3), + ), + ); + } +} + +// ─── Cover Art Page ────────────────────────────────────────────────────────── +class _CoverArtPage extends StatelessWidget { + final PlaybackItem item; + final ColorScheme colorScheme; + + const _CoverArtPage({required this.item, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: AspectRatio( + aspectRatio: 1, + child: _CoverArt( + url: item.coverUrl, + isLocal: item.hasLocalCover, + size: double.infinity, + borderRadius: 20, + ), + ), + ), + ); + } +} + +// ─── Lyrics Page ───────────────────────────────────────────────────────────── +class _LyricsPage extends StatelessWidget { + final PlaybackState state; + final ColorScheme colorScheme; + final VoidCallback onRetry; + final ValueChanged? onSeek; + + const _LyricsPage({ + required this.state, + required this.colorScheme, + required this.onRetry, + required this.onSeek, + }); + + @override + Widget build(BuildContext context) { + if (state.lyricsLoading) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 12), + Text( + 'Loading lyrics...', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + final lyrics = state.lyrics; + if (lyrics == null || lyrics.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lyrics_outlined, + size: 48, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 12), + Text( + 'No lyrics available', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Retry'), + ), + ], + ), + ); + } + + if (lyrics.instrumental) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.music_note_rounded, + size: 48, + color: colorScheme.primary.withValues(alpha: 0.6), + ), + const SizedBox(height: 12), + Text( + 'Instrumental', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + if (lyrics.isSynced) { + return _SyncedLyricsView( + lyrics: lyrics, + positionMs: state.position.inMilliseconds, + colorScheme: colorScheme, + onSeek: onSeek, + ); + } + + // Unsynced lyrics: simple scrollable text + return _UnsyncedLyricsView(lyrics: lyrics, colorScheme: colorScheme); + } +} + +// ─── Synced Lyrics View (line + word-by-word) ──────────────────────────────── +class _SyncedLyricsView extends StatefulWidget { + final LyricsData lyrics; + final int positionMs; + final ColorScheme colorScheme; + final ValueChanged? onSeek; + + const _SyncedLyricsView({ + required this.lyrics, + required this.positionMs, + required this.colorScheme, + required this.onSeek, + }); + + @override + State<_SyncedLyricsView> createState() => _SyncedLyricsViewState(); +} + +class _SyncedLyricsViewState extends State<_SyncedLyricsView> { + final ScrollController _scrollController = ScrollController(); + final GlobalKey _currentLineKey = GlobalKey(); + int _lastScrolledLine = -1; + int _lastQueuedScrollLine = -1; + int? _pendingAutoScrollLine; + bool _userScrolling = false; + bool _isAutoScrolling = false; + Timer? _userScrollTimer; + double _viewHeight = 400; + + @override + void dispose() { + _scrollController.dispose(); + _userScrollTimer?.cancel(); + super.dispose(); + } + + int _findCurrentLineIndex() { + final pos = widget.positionMs; + final lines = widget.lyrics.lines; + if (lines.isEmpty) return -1; + + // Binary search: find the last line whose startMs <= current position. + var left = 0; + var right = lines.length - 1; + var result = -1; + while (left <= right) { + final mid = left + ((right - left) >> 1); + if (lines[mid].startMs <= pos) { + result = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + return result; + } + + double? _targetOffsetFromCurrentLineKey() { + if (!_scrollController.hasClients) return null; + final keyContext = _currentLineKey.currentContext; + if (keyContext == null) return null; + final renderObject = keyContext.findRenderObject(); + if (renderObject == null) return null; + final viewport = RenderAbstractViewport.of(renderObject); + final target = viewport.getOffsetToReveal(renderObject, 0.4).offset; + return target + .clamp(0.0, _scrollController.position.maxScrollExtent) + .toDouble(); + } + + Duration _autoScrollDuration(double distancePx) { + final clampedDistance = distancePx.clamp(80.0, 900.0); + var ms = (160 + (clampedDistance / 2.4)).round(); + if (ms < 180) ms = 180; + if (ms > 560) ms = 560; + return Duration(milliseconds: ms); + } + + Future _scrollToLine(int index) async { + if (_userScrolling || !_scrollController.hasClients) return; + if (_isAutoScrolling) { + _pendingAutoScrollLine = index; + return; + } + if (index == _lastScrolledLine) return; + _lastScrolledLine = index; + + double targetOffset; + final fromKey = _targetOffsetFromCurrentLineKey(); + if (fromKey != null) { + targetOffset = fromKey; + } else { + // Fallback: estimate-based scroll for off-screen items + const lineHeight = 44.0; + final topPad = _viewHeight * 0.4; + targetOffset = topPad + (index * lineHeight) - (_viewHeight * 0.4); + targetOffset = targetOffset + .clamp(0.0, _scrollController.position.maxScrollExtent) + .toDouble(); + } + + final distance = (targetOffset - _scrollController.offset).abs(); + if (distance < 1.0) return; + + _isAutoScrolling = true; + try { + await _scrollController.animateTo( + targetOffset, + duration: _autoScrollDuration(distance), + curve: Curves.easeInOutCubicEmphasized, + ); + } catch (_) { + // Ignore interrupted scroll animations; latest queued target will run next. + } finally { + _isAutoScrolling = false; + final pending = _pendingAutoScrollLine; + _pendingAutoScrollLine = null; + if (pending != null && pending != index && mounted) { + unawaited(_scrollToLine(pending)); + } + } + } + + @override + Widget build(BuildContext context) { + final currentLine = _findCurrentLineIndex(); + + // Auto-scroll only when the target line changes. + if (currentLine >= 0 && currentLine != _lastQueuedScrollLine) { + _lastQueuedScrollLine = currentLine; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_scrollToLine(currentLine)); + } + }); + } + + return LayoutBuilder( + builder: (context, constraints) { + _viewHeight = constraints.maxHeight; + + return NotificationListener( + onNotification: (notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + _userScrolling = true; + _userScrollTimer?.cancel(); + _pendingAutoScrollLine = null; + } + if (notification is ScrollEndNotification && _userScrolling) { + _userScrollTimer = Timer(const Duration(seconds: 4), () { + _userScrolling = false; + _isAutoScrolling = false; + _lastScrolledLine = -1; // Force re-scroll + _lastQueuedScrollLine = -1; + _pendingAutoScrollLine = null; + }); + } + return false; + }, + child: ListView.builder( + controller: _scrollController, + padding: EdgeInsets.only( + left: 24, + right: 24, + top: _viewHeight * 0.4, + bottom: _viewHeight * 0.4, + ), + itemCount: widget.lyrics.lines.length, + itemBuilder: (context, index) { + final line = widget.lyrics.lines[index]; + final isCurrent = index == currentLine; + final isPast = index < currentLine; + + Widget lineWidget; + + if (line.text.isEmpty) { + // Empty line = interlude gap + lineWidget = const SizedBox(height: 32); + } else { + // Target style — AnimatedDefaultTextStyle will + // smoothly tween fontSize / fontWeight / color. + final targetStyle = TextStyle( + fontSize: isCurrent ? 24 : 19, + fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w500, + color: isCurrent + ? widget.colorScheme.onSurface + : isPast + ? widget.colorScheme.onSurfaceVariant.withValues( + alpha: 0.35, + ) + : widget.colorScheme.onSurfaceVariant.withValues( + alpha: 0.55, + ), + height: 1.4, + ); + + lineWidget = GestureDetector( + onTap: widget.onSeek == null + ? null + : () => widget.onSeek!(line.startMs), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + style: targetStyle, + child: line.hasWordSync + ? _WordByWordLine( + line: line, + positionMs: widget.positionMs, + colorScheme: widget.colorScheme, + isCurrent: isCurrent, + ) + : Text(line.text), + ), + ), + ); + } + + // Attach key to the current line for scroll targeting. + if (isCurrent && line.text.isNotEmpty) { + return KeyedSubtree(key: _currentLineKey, child: lineWidget); + } + return lineWidget; + }, + ), + ); + }, + ); + } +} + +// ─── Word-by-Word Highlighted Line ─────────────────────────────────────────── +class _WordByWordLine extends StatelessWidget { + final LyricsLine line; + final int positionMs; + final ColorScheme colorScheme; + final bool isCurrent; + + const _WordByWordLine({ + required this.line, + required this.positionMs, + required this.colorScheme, + required this.isCurrent, + }); + + @override + Widget build(BuildContext context) { + // When not the current line, render plain text that inherits the + // animated style from the parent AnimatedDefaultTextStyle. + if (!isCurrent) { + return Text(line.text); + } + + // Current line: word-by-word gradient sweep + final baseStyle = TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.4, + ); + final inactiveColor = colorScheme.onSurfaceVariant.withValues(alpha: 0.35); + final sungColor = colorScheme.onSurface; + final activeColor = colorScheme.primary; + + return Wrap( + children: line.words.map((word) { + final isCurrentWord = + positionMs >= word.startMs && positionMs < word.endMs; + final isSung = positionMs >= word.endMs; + final wordProgress = isSung + ? 1.0 + : isCurrentWord && word.endMs > word.startMs + ? ((positionMs - word.startMs) / (word.endMs - word.startMs)).clamp( + 0.0, + 1.0, + ) + : 0.0; + + return _AnimatedWordToken( + text: word.text, + progress: wordProgress, + isCurrentWord: isCurrentWord, + baseStyle: baseStyle, + inactiveColor: inactiveColor, + sungColor: sungColor, + activeColor: activeColor, + ); + }).toList(), + ); + } +} + +class _AnimatedWordToken extends StatelessWidget { + final String text; + final double progress; + final bool isCurrentWord; + final TextStyle baseStyle; + final Color inactiveColor; + final Color sungColor; + final Color activeColor; + + const _AnimatedWordToken({ + required this.text, + required this.progress, + required this.isCurrentWord, + required this.baseStyle, + required this.inactiveColor, + required this.sungColor, + required this.activeColor, + }); + + @override + Widget build(BuildContext context) { + final p = progress.clamp(0.0, 1.0); + final hasSweep = p > 0.0 && p < 1.0; + final settledColor = p >= 1.0 ? sungColor : inactiveColor; + + return AnimatedScale( + scale: isCurrentWord ? 1.04 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + child: Stack( + children: [ + Text(text, style: baseStyle.copyWith(color: settledColor)), + if (hasSweep) + ClipRect( + child: Align( + alignment: Alignment.centerLeft, + widthFactor: p, + child: Text( + text, + style: baseStyle.copyWith(color: activeColor), + ), + ), + ), + ], + ), + ); + } +} + +// ─── Unsynced Lyrics View ──────────────────────────────────────────────────── +class _UnsyncedLyricsView extends StatelessWidget { + final LyricsData lyrics; + final ColorScheme colorScheme; + + const _UnsyncedLyricsView({required this.lyrics, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + itemCount: lyrics.lines.length, + itemBuilder: (context, index) { + final line = lyrics.lines[index]; + if (line.text.isEmpty) return const SizedBox(height: 24); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + line.text, + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface.withValues(alpha: 0.8), + height: 1.5, + ), + ), + ); + }, + ); + } +} + +// ─── Quality + Service Row ─────────────────────────────────────────────────── +class _QualityServiceRow extends StatelessWidget { + final PlaybackItem item; + final ColorScheme colorScheme; + + const _QualityServiceRow({required this.item, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + final qualityLabel = item.qualityLabel; + final serviceLabel = _serviceDisplayName(item.service); + + if (qualityLabel.isEmpty && serviceLabel.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Wrap( + spacing: 8, + runSpacing: 4, + alignment: WrapAlignment.center, + children: [ + if (serviceLabel.isNotEmpty) + _Chip( + icon: Icons.cloud_outlined, + label: serviceLabel, + colorScheme: colorScheme, + ), + if (qualityLabel.isNotEmpty) + _Chip( + icon: Icons.graphic_eq_rounded, + label: qualityLabel, + colorScheme: colorScheme, + ), + ], + ), + ); + } + + String _serviceDisplayName(String service) { + if (service.isEmpty) return ''; + switch (service.toLowerCase()) { + case 'tidal': + return 'Tidal'; + case 'qobuz': + return 'Qobuz'; + case 'amazon': + return 'Amazon Music'; + case 'youtube': + return 'YouTube'; + case 'offline': + return 'Local file'; + default: + if (service.isNotEmpty) { + return service[0].toUpperCase() + service.substring(1); + } + return service; + } + } +} + +class _Chip extends StatelessWidget { + final IconData icon; + final String label; + final ColorScheme colorScheme; + + const _Chip({ + required this.icon, + required this.label, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onPrimaryContainer), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +// ─── Download Button ───────────────────────────────────────────────────────── +class _DownloadButton extends ConsumerWidget { + final PlaybackItem item; + final bool compact; + + const _DownloadButton({required this.item, this.compact = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final track = item.track; + if (track == null) return const SizedBox.shrink(); + + final colorScheme = Theme.of(context).colorScheme; + final iconSize = compact ? 18.0 : 22.0; + + return IconButton( + visualDensity: compact ? VisualDensity.compact : VisualDensity.standard, + icon: Icon( + Icons.download_rounded, + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + onPressed: () => _onDownloadTap(context, ref, track), + tooltip: context.l10n.downloadTitle, + ); + } + + void _onDownloadTap(BuildContext context, WidgetRef ref, Track track) { + final settings = ref.read(settingsProvider); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); + }, + ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + } +} + +// ─── Playback Controls ─────────────────────────────────────────────────────── +class _PlaybackControls extends ConsumerWidget { + final PlaybackState state; + final bool compact; + + const _PlaybackControls({required this.state, this.compact = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final notifier = ref.read(playbackProvider.notifier); + final hasPrev = + state.hasPrevious || state.repeatMode == playback_types.RepeatMode.all; + final hasNext = + state.hasNext || state.repeatMode == playback_types.RepeatMode.all; + final sideIconSize = compact ? 18.0 : 22.0; + final skipIconSize = compact ? 28.0 : 32.0; + final mainButtonSize = compact ? 54.0 : 64.0; + final mainIconSize = compact ? 30.0 : 36.0; + final loadingSize = compact ? 24.0 : 28.0; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Shuffle + IconButton( + visualDensity: compact + ? VisualDensity.compact + : VisualDensity.standard, + icon: Icon( + Icons.shuffle_rounded, + color: state.shuffle + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + size: sideIconSize, + ), + onPressed: notifier.toggleShuffle, + tooltip: 'Shuffle', + ), + SizedBox(width: compact ? 2 : 4), + + // Previous + IconButton( + iconSize: skipIconSize, + visualDensity: compact + ? VisualDensity.compact + : VisualDensity.standard, + onPressed: hasPrev ? notifier.skipPrevious : null, + icon: Icon( + Icons.skip_previous_rounded, + color: hasPrev + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + tooltip: 'Previous', + ), + SizedBox(width: compact ? 4 : 8), + + // Play / Pause (large) + SizedBox( + width: mainButtonSize, + height: mainButtonSize, + child: IconButton.filled( + iconSize: mainIconSize, + onPressed: notifier.togglePlayPause, + icon: state.isBuffering || state.isLoading + ? SizedBox( + width: loadingSize, + height: loadingSize, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: colorScheme.onPrimary, + ), + ) + : Icon( + state.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), + tooltip: state.isPlaying ? 'Pause' : 'Play', + ), + ), + SizedBox(width: compact ? 4 : 8), + + // Next + IconButton( + iconSize: skipIconSize, + visualDensity: compact + ? VisualDensity.compact + : VisualDensity.standard, + onPressed: hasNext ? notifier.skipNext : null, + icon: Icon( + Icons.skip_next_rounded, + color: hasNext + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + tooltip: 'Next', + ), + SizedBox(width: compact ? 2 : 4), + + // Repeat + IconButton( + visualDensity: compact + ? VisualDensity.compact + : VisualDensity.standard, + icon: Icon( + state.repeatMode == playback_types.RepeatMode.one + ? Icons.repeat_one_rounded + : Icons.repeat_rounded, + color: state.repeatMode != playback_types.RepeatMode.off + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + size: sideIconSize, + ), + onPressed: notifier.cycleRepeatMode, + tooltip: _repeatTooltip(state.repeatMode), + ), + ], + ); + } + + String _repeatTooltip(playback_types.RepeatMode mode) { + switch (mode) { + case playback_types.RepeatMode.off: + return 'Repeat: Off'; + case playback_types.RepeatMode.all: + return 'Repeat: All'; + case playback_types.RepeatMode.one: + return 'Repeat: One'; + } + } +} + +// ─── Cover Art Widget (supports both network and local) ────────────────────── +class _CoverArt extends StatelessWidget { + final String url; + final bool isLocal; + final double size; + final double borderRadius; + + const _CoverArt({ + required this.url, + required this.isLocal, + required this.size, + required this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (url.trim().isEmpty) { + return _placeholder(colorScheme); + } + + if (isLocal) { + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: Image.file( + File(url), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: size.isFinite ? (size * 3).toInt() : null, + cacheHeight: size.isFinite ? (size * 3).toInt() : null, + errorBuilder: (_, _, _) => _placeholder(colorScheme), + ), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: CachedNetworkImage( + imageUrl: url, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: size.isFinite ? (size * 3).toInt() : null, + memCacheHeight: size.isFinite ? (size * 3).toInt() : null, + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => _placeholder(colorScheme), + ), + ); + } + + Widget _placeholder(ColorScheme colorScheme) { + final iconSize = size.isFinite ? size * 0.4 : 48.0; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Icon( + Icons.music_note_rounded, + size: iconSize, + color: colorScheme.onSurfaceVariant, + ), + ); + } +} + +// ─── Queue Bottom Sheet ────────────────────────────────────────────────────── +class _QueueBottomSheet extends ConsumerWidget { + final WidgetRef ref; + + const _QueueBottomSheet({required this.ref}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(playbackProvider); + final playbackNotifier = ref.read(playbackProvider.notifier); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final queue = state.queue; + final displayOrder = playbackNotifier.getQueueDisplayOrder(); + final currentDisplayIndex = playbackNotifier.getCurrentDisplayQueuePosition( + displayOrder: displayOrder, + ); + if (queue.isEmpty || displayOrder.isEmpty || currentDisplayIndex < 0) { + return const SizedBox.shrink(); + } + + return DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.3, + maxChildSize: 0.92, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon( + Icons.queue_music_rounded, + size: 22, + color: colorScheme.primary, + ), + const SizedBox(width: 10), + Text( + 'Queue', + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + '${queue.length} tracks', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + const Divider(height: 1), + + // Queue list + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.only(bottom: 16), + itemCount: + queue.length + + _sectionHeaderCount(currentDisplayIndex, queue.length), + itemBuilder: (context, index) { + // Calculate real item index accounting for section headers + return _buildQueueListItem( + context, + ref, + index, + queue, + displayOrder, + currentDisplayIndex, + colorScheme, + textTheme, + ); + }, + ), + ), + ], + ); + }, + ); + } + + int _sectionHeaderCount(int currentIndex, int queueLength) { + int count = 0; + if (currentIndex > 0) count++; // "Already Played" header + count++; // "Now Playing" header + if (currentIndex < queueLength - 1) count++; // "Up Next" header + return count; + } + + Widget _buildQueueListItem( + BuildContext context, + WidgetRef ref, + int listIndex, + List queue, + List displayOrder, + int currentDisplayIndex, + ColorScheme colorScheme, + TextTheme textTheme, + ) { + // Build a flat list: [played header?, played items, now playing header, + // now playing item, up next header?, up next items] + int offset = 0; + + // Section: Already Played + if (currentDisplayIndex > 0) { + if (listIndex == offset) { + return _sectionHeader( + 'Played', + Icons.history_rounded, + colorScheme, + textTheme, + ); + } + offset++; + if (listIndex < offset + currentDisplayIndex) { + final displayIdx = listIndex - offset; + final queueIdx = displayOrder[displayIdx]; + return _queueTrackTile( + context, + ref, + queue[queueIdx], + queueIdx, + displayIdx, + colorScheme, + textTheme, + isPlayed: true, + ); + } + offset += currentDisplayIndex; + } + + // Section: Now Playing + if (listIndex == offset) { + return _sectionHeader( + 'Now Playing', + Icons.play_circle_filled_rounded, + colorScheme, + textTheme, + isPrimary: true, + ); + } + offset++; + if (listIndex == offset) { + final queueIdx = displayOrder[currentDisplayIndex]; + return _queueTrackTile( + context, + ref, + queue[queueIdx], + queueIdx, + currentDisplayIndex, + colorScheme, + textTheme, + isCurrent: true, + ); + } + offset++; + + // Section: Up Next + if (currentDisplayIndex < queue.length - 1) { + if (listIndex == offset) { + final upNextCount = queue.length - currentDisplayIndex - 1; + return _sectionHeader( + 'Up Next ($upNextCount)', + Icons.skip_next_rounded, + colorScheme, + textTheme, + ); + } + offset++; + final displayIdx = currentDisplayIndex + 1 + (listIndex - offset); + if (displayIdx < queue.length) { + final queueIdx = displayOrder[displayIdx]; + return _queueTrackTile( + context, + ref, + queue[queueIdx], + queueIdx, + displayIdx, + colorScheme, + textTheme, + ); + } + } + + return const SizedBox.shrink(); + } + + Widget _sectionHeader( + String title, + IconData icon, + ColorScheme colorScheme, + TextTheme textTheme, { + bool isPrimary = false, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), + child: Row( + children: [ + Icon( + icon, + size: 16, + color: isPrimary + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + title, + style: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: isPrimary + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _queueTrackTile( + BuildContext context, + WidgetRef ref, + PlaybackItem item, + int queueIndex, + int displayIndex, + ColorScheme colorScheme, + TextTheme textTheme, { + bool isCurrent = false, + bool isPlayed = false, + }) { + final opacity = isPlayed ? 0.5 : 1.0; + + return Material( + color: isCurrent + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + child: InkWell( + onTap: isCurrent + ? null + : () { + ref.read(playbackProvider.notifier).playQueueIndex(queueIndex); + Navigator.of(context).pop(); + }, + child: Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Track number in queue + SizedBox( + width: 28, + child: Text( + '${displayIndex + 1}', + textAlign: TextAlign.center, + style: textTheme.bodySmall?.copyWith( + color: isCurrent + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + const SizedBox(width: 8), + // Cover art + _CoverArt( + url: item.coverUrl, + isLocal: item.hasLocalCover, + size: 44, + borderRadius: 8, + ), + const SizedBox(width: 12), + // Track info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: isCurrent + ? FontWeight.w700 + : FontWeight.w500, + color: isCurrent + ? colorScheme.primary + : colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + item.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Now playing indicator + if (isCurrent) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + Icons.equalizer_rounded, + size: 20, + color: colorScheme.primary, + ), + ), + // Remove from queue button (for up next items only) + if (!isCurrent && !isPlayed) + IconButton( + icon: Icon( + Icons.close_rounded, + size: 18, + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), + ), + onPressed: () { + ref + .read(playbackProvider.notifier) + .removeFromQueue(queueIndex); + }, + visualDensity: VisualDensity.compact, + tooltip: 'Remove', + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 525d3b28..2b6fd162 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -13,134 +13,9 @@ Future showAddTrackToPlaylistSheet( WidgetRef ref, Track track, ) async { - final notifier = ref.read(libraryCollectionsProvider.notifier); - final state = ref.read(libraryCollectionsProvider); - - if (!context.mounted) return; - - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - final playlists = ref.watch( - libraryCollectionsProvider.select((state) => state.playlists), - ); - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.playlist_add), - title: Text(sheetContext.l10n.collectionAddToPlaylist), - subtitle: Text('${track.name} • ${track.artistName}'), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add_circle_outline), - title: Text(sheetContext.l10n.collectionCreatePlaylist), - onTap: () async { - Navigator.of(sheetContext).pop(); - final name = await _promptPlaylistName(context); - if (name == null || name.trim().isEmpty || !context.mounted) { - return; - } - final playlistId = await notifier.createPlaylist(name.trim()); - final added = await notifier.addTrackToPlaylist( - playlistId, - track, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n.collectionAddedToPlaylist(name.trim()) - : context.l10n.collectionAlreadyInPlaylist( - name.trim(), - ), - ), - ), - ); - }, - ), - if (playlists.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Text( - sheetContext.l10n.collectionNoPlaylistsYet, - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), - ), - ) - else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 320), - child: ListView.builder( - shrinkWrap: true, - itemCount: playlists.length, - itemBuilder: (context, index) { - final playlist = playlists[index]; - final alreadyInPlaylist = playlist.containsTrack(track); - return ListTile( - leading: _PlaylistPickerThumbnail( - playlist: playlist, - isSelected: alreadyInPlaylist, - ), - title: Text(playlist.name), - subtitle: Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, - ), - ), - enabled: !alreadyInPlaylist, - onTap: !alreadyInPlaylist - ? () async { - final added = await notifier.addTrackToPlaylist( - playlist.id, - track, - ); - if (!context.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n - .collectionAddedToPlaylist( - playlist.name, - ) - : context.l10n - .collectionAlreadyInPlaylist( - playlist.name, - ), - ), - ), - ); - } - : null, - ); - }, - ), - ), - const SizedBox(height: 8), - ], - ), - ); - }, - ); - - if (!context.mounted) return; - - final afterState = ref.read(libraryCollectionsProvider); - if (afterState.playlists.length != state.playlists.length) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), - ); - } + return showAddTracksToPlaylistSheet(context, ref, [track]); } -/// Batch version: add multiple tracks to a chosen playlist. Future showAddTracksToPlaylistSheet( BuildContext context, WidgetRef ref, @@ -148,79 +23,157 @@ Future showAddTracksToPlaylistSheet( ) async { if (tracks.isEmpty) return; - final notifier = ref.read(libraryCollectionsProvider.notifier); - if (!context.mounted) return; await showModalBottomSheet( context: context, + useRootNavigator: true, showDragHandle: true, + isScrollControlled: true, builder: (sheetContext) { - final playlists = ref.watch( - libraryCollectionsProvider.select((state) => state.playlists), + return _PlaylistPickerSheetContent(tracks: tracks); + }, + ); +} + +class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { + final List tracks; + + const _PlaylistPickerSheetContent({required this.tracks}); + + @override + ConsumerState<_PlaylistPickerSheetContent> createState() => + _PlaylistPickerSheetContentState(); +} + +class _PlaylistPickerSheetContentState + extends ConsumerState<_PlaylistPickerSheetContent> { + final Set _selectedPlaylistIds = {}; + final Set _initialDisabledIds = {}; + bool _initialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + final playlists = ref.read(libraryCollectionsProvider).playlists; + for (final playlist in playlists) { + final alreadyInPlaylist = + widget.tracks.every((t) => playlist.containsTrack(t)); + if (alreadyInPlaylist) { + _initialDisabledIds.add(playlist.id); + _selectedPlaylistIds.add(playlist.id); + } + } + _initialized = true; + } + } + + void _handleDone() async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final addedNames = []; + + for (final playlistId in idsToAdd) { + final playlist = + ref.read(libraryCollectionsProvider).playlistById(playlistId); + if (playlist != null) { + addedNames.add(playlist.name); + } + await notifier.addTracksToPlaylist(playlistId, widget.tracks); + } + + if (!mounted) return; + Navigator.of(context).pop(); + + if (addedNames.isNotEmpty) { + final name = + addedNames.length == 1 ? addedNames.first : addedNames.join(', '); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.collectionAddedToPlaylist(name)), + ), ); - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.playlist_add), - title: Text(sheetContext.l10n.collectionAddToPlaylist), - subtitle: Text( - '${tracks.length} ${tracks.length == 1 ? 'track' : 'tracks'}', - ), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add_circle_outline), - title: Text(sheetContext.l10n.collectionCreatePlaylist), - onTap: () async { - Navigator.of(sheetContext).pop(); - final name = await _promptPlaylistName(context); - if (name == null || name.trim().isEmpty || !context.mounted) { - return; - } - final playlistId = await notifier.createPlaylist(name.trim()); - final result = await notifier.addTracksToPlaylist( - playlistId, - tracks, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.addedCount > 0 - ? context.l10n.collectionAddedToPlaylist(name.trim()) - : context.l10n.collectionAlreadyInPlaylist( - name.trim(), - ), - ), - ), - ); - }, - ), - if (playlists.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Text( - sheetContext.l10n.collectionNoPlaylistsYet, - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), + } + } + + @override + Widget build(BuildContext context) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + final notifier = ref.read(libraryCollectionsProvider.notifier); + + final String subtitle; + if (widget.tracks.length == 1) { + final track = widget.tracks.first; + subtitle = '${track.name} • ${track.artistName}'; + } else { + subtitle = + '${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}'; + } + + final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final hasNewSelections = idsToAdd.isNotEmpty; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(context.l10n.collectionAddToPlaylist), + subtitle: Text(subtitle), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(context.l10n.collectionCreatePlaylist), + onTap: () async { + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + await notifier.addTracksToPlaylist(playlistId, widget.tracks); + setState(() { + _initialDisabledIds.add(playlistId); + _selectedPlaylistIds.add(playlistId); + }); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.collectionAddedToPlaylist(name.trim())), ), - ) - else - ConstrainedBox( + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + else + Flexible( + child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 320), child: ListView.builder( shrinkWrap: true, itemCount: playlists.length, itemBuilder: (context, index) { final playlist = playlists[index]; + final isAlreadyIn = _initialDisabledIds.contains(playlist.id); + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return ListTile( leading: _PlaylistPickerThumbnail( playlist: playlist, - isSelected: false, + isSelected: isSelected, ), title: Text(playlist.name), subtitle: Text( @@ -228,37 +181,44 @@ Future showAddTracksToPlaylistSheet( playlist.tracks.length, ), ), - onTap: () async { - final result = await notifier.addTracksToPlaylist( - playlist.id, - tracks, - ); - if (!context.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.addedCount > 0 - ? context.l10n.collectionAddedToPlaylist( - playlist.name, - ) - : context.l10n.collectionAlreadyInPlaylist( - playlist.name, - ), - ), - ), - ); - }, + enabled: !isAlreadyIn, + onTap: !isAlreadyIn + ? () { + setState(() { + if (isSelected) { + _selectedPlaylistIds.remove(playlist.id); + } else { + _selectedPlaylistIds.add(playlist.id); + } + }); + } + : null, ); }, ), ), - const SizedBox(height: 8), - ], - ), - ); - }, - ); + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + if (hasNewSelections) { + _handleDone(); + } else { + Navigator.of(context).pop(); + } + }, + child: Text(context.l10n.dialogDone), + ), + ), + ), + ], + ), + ); + } } Future _promptPlaylistName(BuildContext context) async { @@ -352,10 +312,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.primary, shape: BoxShape.circle, - border: Border.all( - color: colorScheme.primary, - width: 1.5, - ), + border: Border.all(color: colorScheme.primary, width: 1.5), ), child: Icon( Icons.check, diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 1f9e345d..601fac9c 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -3,17 +3,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class TrackCollectionQuickActions extends ConsumerWidget { final Track track; - const TrackCollectionQuickActions({ - super.key, - required this.track, - }); + const TrackCollectionQuickActions({super.key, required this.track}); + + static void showTrackOptionsSheet( + BuildContext context, + WidgetRef ref, + Track track, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _TrackOptionsSheet(track: track), + ); + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -25,24 +44,11 @@ class TrackCollectionQuickActions extends ConsumerWidget { color: colorScheme.onSurfaceVariant, size: 20, ), - onPressed: () => _showTrackOptionsSheet(context, ref), + onPressed: () => showTrackOptionsSheet(context, ref, track), padding: const EdgeInsets.only(left: 12), constraints: const BoxConstraints(minWidth: 36, minHeight: 36), ); } - - void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => _TrackOptionsSheet(track: track), - ); - } } class _TrackOptionsSheet extends ConsumerWidget { @@ -53,6 +59,9 @@ class _TrackOptionsSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + final settings = ref.watch(settingsProvider); + final rootContext = Navigator.of(context, rootNavigator: true).context; + final container = ProviderScope.containerOf(rootContext, listen: false); final isLoved = ref.watch( libraryCollectionsProvider.select((state) => state.isLoved(track)), @@ -62,155 +71,214 @@ class _TrackOptionsSheet extends ConsumerWidget { ); return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header with drag handle + track info (matches _TrackInfoHeader) - Column( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && track.coverUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - cacheManager: CoverCacheManager.instance, - errorWidget: (context, url, error) => Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), + // Header with drag handle + track info (matches _TrackInfoHeader) + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - track.name, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - track.artistName, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + track.coverUrl != null && + track.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => + Container( + width: 56, + height: 56, + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Action items (matches _QualityOption style) + _OptionTile( + icon: Icons.download_rounded, + title: context.l10n.downloadTitle, + onTap: () async { + Navigator.pop(context); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + rootContext, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + container + .read(downloadQueueProvider.notifier) + .addToQueue( + track, + service, + qualityOverride: quality, + ); + ScaffoldMessenger.of(rootContext).showSnackBar( + SnackBar( + content: Text( + rootContext.l10n.snackbarAddedToQueue(track.name), + ), + ), + ); + }, + ); + } else { + container + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + if (!rootContext.mounted) { + return; + } + ScaffoldMessenger.of(rootContext).showSnackBar( + SnackBar( + content: Text( + rootContext.l10n.snackbarAddedToQueue(track.name), + ), + ), + ); + } + }, + ), + _OptionTile( + icon: isLoved ? Icons.favorite : Icons.favorite_border, + iconColor: isLoved ? colorScheme.error : null, + title: isLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToLoved(track.name) + : context.l10n.collectionRemovedFromLoved( + track.name, + ), ), ), - ], - ), + ); + }, ), + _OptionTile( + icon: isInWishlist + ? Icons.playlist_add_check_circle + : Icons.add_circle_outline, + iconColor: isInWishlist ? colorScheme.primary : null, + title: isInWishlist + ? context.l10n.trackOptionRemoveFromWishlist + : context.l10n.trackOptionAddToWishlist, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleWishlist(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToWishlist(track.name) + : context.l10n.collectionRemovedFromWishlist( + track.name, + ), + ), + ), + ); + }, + ), + _OptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(context); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + const SizedBox(height: 16), ], ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - - // Action items (matches _QualityOption style) - _OptionTile( - icon: isLoved ? Icons.favorite : Icons.favorite_border, - iconColor: isLoved ? colorScheme.error : null, - title: isLoved - ? context.l10n.trackOptionRemoveFromLoved - : context.l10n.trackOptionAddToLoved, - onTap: () async { - Navigator.pop(context); - final added = await ref - .read(libraryCollectionsProvider.notifier) - .toggleLoved(track); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n.collectionAddedToLoved(track.name) - : context.l10n.collectionRemovedFromLoved(track.name), - ), - ), - ); - }, - ), - _OptionTile( - icon: isInWishlist - ? Icons.playlist_add_check_circle - : Icons.add_circle_outline, - iconColor: isInWishlist ? colorScheme.primary : null, - title: isInWishlist - ? context.l10n.trackOptionRemoveFromWishlist - : context.l10n.trackOptionAddToWishlist, - onTap: () async { - Navigator.pop(context); - final added = await ref - .read(libraryCollectionsProvider.notifier) - .toggleWishlist(track); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n.collectionAddedToWishlist(track.name) - : context.l10n.collectionRemovedFromWishlist( - track.name), - ), - ), - ); - }, - ), - _OptionTile( - icon: Icons.playlist_add, - title: context.l10n.collectionAddToPlaylist, - onTap: () { - Navigator.pop(context); - showAddTrackToPlaylistSheet(context, ref, track); - }, - ), - - const SizedBox(height: 16), - ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index a250b404..3492ac5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 + url: "https://pub.dev" + source: hosted + version: "0.18.18" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: "direct main" + description: + name: audio_session + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" boolean_selector: dependency: transitive description: @@ -233,14 +265,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dart_style: dependency: transitive description: @@ -273,22 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" dynamic_color: dependency: "direct main" description: @@ -313,11 +321,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - ffmpeg_kit_flutter_new_audio: + ffmpeg_kit_flutter_new_full: dependency: "direct main" description: - name: ffmpeg_kit_flutter_new_audio - sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae" + name: ffmpeg_kit_flutter_new_full + sha256: "48938db8d1bfb5ab4409d4291aedf99563e033dd7430ce41b5a677945a821679" url: "https://pub.dev" source: hosted version: "2.0.0" @@ -483,14 +491,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" - url: "https://pub.dev" - source: hosted - version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -613,6 +613,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.2" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" + url: "https://pub.dev" + source: hosted + version: "0.10.5" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -670,7 +694,7 @@ packages: source: hosted version: "0.12.17" material_color_utilities: - dependency: "direct main" + dependency: transitive description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec @@ -749,14 +773,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -934,7 +950,7 @@ packages: source: hosted version: "1.0.0-dev.8" riverpod_annotation: - dependency: "direct main" + dependency: transitive description: name: riverpod_annotation sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 @@ -1314,30 +1330,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61840f87..f6cefdfe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.7.0+83 +version: 4.0.1+102 environment: sdk: ^3.10.0 @@ -17,7 +17,6 @@ dependencies: # State Management flutter_riverpod: ^3.1.0 - riverpod_annotation: ^4.0.0 # Navigation go_router: ^17.0.1 @@ -31,18 +30,14 @@ dependencies: # HTTP & Network http: ^1.6.0 - dio: ^5.8.0 connectivity_plus: 7.0.0 # UI Components - cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 - flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 - material_color_utilities: ">=0.11.1 <0.14.0" # Permissions permission_handler: ^12.0.1 @@ -61,11 +56,14 @@ dependencies: logger: ^2.5.0 # FFmpeg for audio conversion - ffmpeg_kit_flutter_new_audio: ^2.0.0 + ffmpeg_kit_flutter_new_full: ^2.0.0 open_filex: ^4.7.0 # Notifications flutter_local_notifications: 20.0.0 + just_audio: ^0.10.5 + audio_session: ^0.2.2 + audio_service: ^0.18.17 dev_dependencies: flutter_test: From 2fe8f659bca083e55a09fff278c41796c87ddb4a Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 13:47:40 +0700 Subject: [PATCH 21/38] refactor: simplify setup flow and update collection actions --- lib/screens/album_screen.dart | 2 +- lib/screens/library_tracks_folder_screen.dart | 79 ++------- lib/screens/playlist_screen.dart | 28 +-- lib/screens/setup_screen.dart | 166 +----------------- 4 files changed, 24 insertions(+), 251 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index a6516270..5cd0d030 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -826,7 +826,7 @@ class _AlbumTrackItem extends ConsumerWidget { style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), - if (isInLocalLibrary) ...[ + if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 4198be85..3d66b196 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -798,7 +798,7 @@ class _LibraryTracksFolderScreenState _buildShuffleButton(entries), const SizedBox(width: 12), ], - _buildDownloadAllCenterButton(context, entries), + _buildPlayAllCenterButton(entries), ], ), ], @@ -831,7 +831,7 @@ class _LibraryTracksFolderScreenState ); } - // ── Shuffle / Download buttons ── + // ── Shuffle / Play buttons ── Widget _buildShuffleButton(List entries) { return Container( @@ -854,15 +854,12 @@ class _LibraryTracksFolderScreenState ); } - Widget _buildDownloadAllCenterButton( - BuildContext context, - List entries, - ) { + Widget _buildPlayAllCenterButton(List entries) { final tracks = entries.map((e) => e.track).toList(growable: false); return FilledButton.icon( - onPressed: tracks.isEmpty ? null : () => _downloadAll(context, tracks), - icon: const Icon(Icons.download_rounded, size: 18), - label: Text(context.l10n.downloadAllCount(tracks.length)), + onPressed: tracks.isEmpty ? null : () => _playAll(tracks), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text(context.l10n.playAllCount(tracks.length)), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, @@ -885,65 +882,15 @@ class _LibraryTracksFolderScreenState }); } - void _downloadAll(BuildContext context, List tracks) { + void _playAll(List tracks) { if (tracks.isEmpty) return; - showDialog( - context: context, - builder: (dialogContext) { - final colorScheme = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: colorScheme.surfaceContainerHigh, - title: const Text('Download All'), - content: Text('Download ${tracks.length} tracks?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - _executeDownloadAll(context, tracks); - }, - child: const Text('Download'), - ), - ], - ); - }, - ); - } - - void _executeDownloadAll(BuildContext context, List tracks) { - final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: '${tracks.length} tracks', - artistName: '', - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(tracks.length), - ), - ), - ); - }, + final messenger = ScaffoldMessenger.of(context); + ref.read(playbackProvider.notifier).playTrackList(tracks).catchError((e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Cannot play local tracks: $e')), ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), - ), - ); - } + }); } void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index ac9a3f76..8d43dc90 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -10,8 +10,8 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart'; 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'; class PlaylistScreen extends ConsumerStatefulWidget { @@ -308,7 +308,7 @@ class _PlaylistScreenState extends ConsumerState { const SizedBox(width: 12), _buildDownloadAllCenterButton(context), const SizedBox(width: 12), - _buildShufflePlayButton(), + _buildAddToPlaylistButton(context), ], ), ], @@ -505,26 +505,16 @@ class _PlaylistScreenState extends ConsumerState { ); } - Widget _buildShufflePlayButton() { + Widget _buildAddToPlaylistButton(BuildContext context) { return _buildCircleButton( - icon: Icons.shuffle_rounded, - tooltip: 'Shuffle Play', - onPressed: _tracks.isEmpty ? null : _shufflePlayLocal, + icon: Icons.playlist_add, + tooltip: 'Add to Playlist', + onPressed: _tracks.isEmpty + ? null + : () => showAddTracksToPlaylistSheet(context, ref, _tracks), ); } - void _shufflePlayLocal() { - if (_tracks.isEmpty) return; - final shuffled = [..._tracks]..shuffle(); - final messenger = ScaffoldMessenger.of(context); - ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) { - if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Cannot shuffle play local tracks: $e')), - ); - }); - } - void _confirmDownloadAll(BuildContext context) { if (_tracks.isEmpty) return; showDialog( @@ -717,7 +707,7 @@ class _PlaylistTrackItem extends ConsumerWidget { style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), - if (isInLocalLibrary) ...[ + if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 8ec82457..77456bd8 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -31,11 +31,7 @@ class _SetupScreenState extends ConsumerState { bool _isLoading = false; int _androidSdkVersion = 0; - // Mode selection - String _selectedMode = 'downloader'; - - // We add 1 for the Welcome step - int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1; + int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3; @override void initState() { @@ -466,8 +462,6 @@ class _SetupScreenState extends ConsumerState { return _notificationPermissionGranted; case 2: return _selectedDirectory != null; - case 3: - return true; // Mode selection always has a default } } else { switch (logicStep) { @@ -475,8 +469,6 @@ class _SetupScreenState extends ConsumerState { return _storagePermissionGranted; case 1: return _selectedDirectory != null; - case 2: - return true; // Mode selection always has a default } } return false; @@ -553,7 +545,6 @@ class _SetupScreenState extends ConsumerState { if (_androidSdkVersion >= 33) _buildNotificationStep(colorScheme), _buildDirectoryStep(colorScheme), - _buildModeSelectionStep(colorScheme), ], ), ), @@ -747,38 +738,6 @@ class _SetupScreenState extends ConsumerState { ), ); } - - Widget _buildModeSelectionStep(ColorScheme colorScheme) { - return _StepLayout( - title: context.l10n.setupModeSelectionTitle, - description: context.l10n.setupModeSelectionDescription, - icon: Icons.tune, - child: Column( - children: [ - _ModeCard( - icon: Icons.download, - title: context.l10n.setupModeDownloaderTitle, - features: [ - context.l10n.setupModeDownloaderFeature1, - context.l10n.setupModeDownloaderFeature2, - context.l10n.setupModeDownloaderFeature3, - ], - isSelected: _selectedMode == 'downloader', - onTap: () => setState(() => _selectedMode = 'downloader'), - colorScheme: colorScheme, - ), - const SizedBox(height: 16), - Text( - context.l10n.setupModeChangeableLater, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ); - } } class _StepLayout extends StatelessWidget { @@ -888,126 +847,3 @@ class _SuccessCard extends StatelessWidget { ); } } - -class _ModeCard extends StatelessWidget { - final IconData icon; - final String title; - final List features; - final bool isSelected; - final VoidCallback onTap; - final ColorScheme colorScheme; - - const _ModeCard({ - required this.icon, - required this.title, - required this.features, - required this.isSelected, - required this.onTap, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outlineVariant, - width: isSelected ? 2 : 1, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - isSelected - ? Icons.radio_button_checked - : Icons.radio_button_unchecked, - size: 22, - color: isSelected - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - icon, - size: 22, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - ...features.map( - (feature) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '\u2022 ', - style: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - Expanded( - child: Text( - feature, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - height: 1.4, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} From e6ffb0895458124e01c3acd87b7a82504c922dce Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:14:44 +0700 Subject: [PATCH 22/38] feat: make smart queue offline-only --- lib/providers/playback_provider.dart | 724 ++++++++++++++++++++------- 1 file changed, 530 insertions(+), 194 deletions(-) diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index c52bc6e2..56f4849a 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; import 'package:spotiflac_android/models/playback_item.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -271,6 +272,7 @@ class PlaybackController extends Notifier { static const int _smartQueueMaxAutoAddsPerSession = 40; static const int _smartQueueRecentPlayedWindow = 40; static const int _smartQueueCandidatePoolLimit = 28; + static const int _smartQueueOfflinePoolMaxItems = 1800; static const int _smartQueueRelatedArtistsLimit = 3; static const int _smartQueueMaxAffinityKeys = 160; static const int _smartQueueSessionWindowSize = 10; @@ -1136,26 +1138,8 @@ class PlaybackController extends Notifier { PlaybackItem _buildQueueItemFromTrack(Track track) { final localState = ref.read(localLibraryProvider); - final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; - - LocalLibraryItem? localItem; - if (isLocalSource) { - for (final item in localState.items) { - if (item.id == track.id) { - localItem = item; - break; - } - } - } - - if (localItem == null) { - final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - localItem = localState.getByIsrc(isrc); - } - } - - localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + final historyState = ref.read(downloadHistoryProvider); + final localItem = _findLocalLibraryItemForTrack(track, localState); if (localItem != null && localItem.filePath.isNotEmpty) { final localUri = _uriFromPath(localItem.filePath); @@ -1177,6 +1161,30 @@ class PlaybackController extends Notifier { ); } + final historyItem = _findDownloadHistoryItemForTrack(track, historyState); + if (historyItem != null && historyItem.filePath.isNotEmpty) { + final localUri = _uriFromPath(historyItem.filePath); + final localDurationMs = + historyItem.duration != null && historyItem.duration! > 0 + ? historyItem.duration! * 1000 + : _trackDurationMs(track); + final playbackId = (historyItem.spotifyId ?? '').trim().isNotEmpty + ? historyItem.spotifyId!.trim() + : historyItem.id; + return PlaybackItem( + id: playbackId, + title: historyItem.trackName, + artist: historyItem.artistName, + album: historyItem.albumName, + coverUrl: historyItem.coverUrl ?? track.coverUrl ?? '', + sourceUri: localUri.toString(), + isLocal: true, + service: 'offline', + durationMs: localDurationMs, + track: track, + ); + } + return PlaybackItem( id: track.id, title: track.name, @@ -1189,6 +1197,73 @@ class PlaybackController extends Notifier { ); } + LocalLibraryItem? _findLocalLibraryItemForTrack( + Track track, + LocalLibraryState localState, + ) { + final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; + if (isLocalSource) { + for (final item in localState.items) { + if (item.id == track.id) { + return item; + } + } + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localState.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; + } + + return localState.findByTrackAndArtist(track.name, track.artistName); + } + + DownloadHistoryItem? _findDownloadHistoryItemForTrack( + Track track, + DownloadHistoryState historyState, + ) { + for (final candidateId in _spotifyIdLookupCandidates(track.id)) { + final bySpotifyId = historyState.getBySpotifyId(candidateId); + if (bySpotifyId != null) return bySpotifyId; + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = historyState.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; + } + + return historyState.findByTrackAndArtist(track.name, track.artistName); + } + + List _spotifyIdLookupCandidates(String rawId) { + final trimmed = rawId.trim(); + if (trimmed.isEmpty) return const []; + + final candidates = {trimmed}; + final lowered = trimmed.toLowerCase(); + if (lowered.startsWith('spotify:track:')) { + final compact = trimmed.split(':').last.trim(); + if (compact.isNotEmpty) candidates.add(compact); + } else if (!trimmed.contains(':')) { + candidates.add('spotify:track:$trimmed'); + } + + final uri = Uri.tryParse(trimmed); + final segments = uri?.pathSegments ?? const []; + final trackIndex = segments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < segments.length) { + final pathId = segments[trackIndex + 1].trim(); + if (pathId.isNotEmpty) { + candidates.add(pathId); + candidates.add('spotify:track:$pathId'); + } + } + + return candidates.toList(growable: false); + } + int _trackDurationMs(Track track) { if (track.duration <= 0) return 0; return track.duration * 1000; @@ -2344,9 +2419,64 @@ class PlaybackController extends Notifier { if (relatedArtistTracks.isNotEmpty) { merged.addAll(relatedArtistTracks); } + + if (merged.isEmpty) { + merged.addAll( + _fallbackOfflineTracksForSmartQueue(seed: seed, limit: max(12, limit)), + ); + } + return merged; } + List _fallbackOfflineTracksForSmartQueue({ + required Track seed, + required int limit, + }) { + if (limit <= 0) return const []; + + final pool = _buildOfflineTrackPoolForSmartQueue( + maxItems: _smartQueueOfflinePoolMaxItems, + ); + if (pool.isEmpty) return const []; + + final seedKey = _trackKeyFromTrack(seed); + final seedArtist = _normalizeSmartQueueKey(seed.artistName); + final seedAlbum = _normalizeSmartQueueKey(seed.albumName); + final seedSource = _sourceKey(seed.source ?? ''); + + final scored = <_OfflineSmartQueueTrackHit>[]; + for (final track in pool) { + final key = _trackKeyFromTrack(track); + if (key.isEmpty || key == seedKey) continue; + + var score = 0.35; + final artistKey = _normalizeSmartQueueKey(track.artistName); + final albumKey = _normalizeSmartQueueKey(track.albumName); + final sourceKey = _sourceKey(track.source ?? ''); + if (artistKey.isNotEmpty && artistKey == seedArtist) { + score += 2.1; + } + if (albumKey.isNotEmpty && albumKey == seedAlbum) { + score += 1.25; + } + if (sourceKey == seedSource) { + score += 0.35; + } + score += _durationSimilarity(seed.duration, track.duration) * 0.55; + score += + _releaseYearSimilarity(seed.releaseDate, track.releaseDate) * 0.3; + score += _smartQueueRandom.nextDouble() * 0.08; + scored.add(_OfflineSmartQueueTrackHit(track: track, score: score)); + } + + scored.sort((a, b) => b.score.compareTo(a.score)); + return scored + .take(limit) + .map((entry) => entry.track) + .toList(growable: false); + } + Future> _fetchRelatedArtistTracksForSmartQueue( Track seed, { required List fallbackTracks, @@ -2484,117 +2614,89 @@ class PlaybackController extends Notifier { Future> _fetchRelatedArtistsFromProviderSeed( _SmartQueueArtistSeed seed, ) async { - try { - if (seed.provider == 'spotify') { - return await _fetchSpotifyRelatedArtistsForSmartQueue(seed); - } else if (seed.provider == 'deezer') { - final response = await PlatformBridge.getDeezerRelatedArtists( - seed.id, - limit: 10, - ); - final rawList = response['artists'] as List? ?? const []; - final result = <_SmartQueueRelatedArtist>[]; - for (final entry in rawList) { - if (entry is! Map) continue; - final map = Map.from(entry); - final name = (map['name'] as String?)?.trim() ?? ''; - if (name.isEmpty) continue; - final popularity = (map['popularity'] as num?)?.toDouble() ?? 0.0; - final followers = (map['followers'] as num?)?.toDouble() ?? 0.0; - final score = - ((popularity / 100.0) * 0.65) + - (min(followers, 2000000) / 2000000.0) * 0.35; - result.add( - _SmartQueueRelatedArtist( - name: name, - provider: seed.provider, - score: score.clamp(0.05, 1.0), - ), - ); - } - return result; - } - return const []; - } catch (_) { - return const []; + if (seed.provider == 'spotify') { + return _fetchSpotifyRelatedArtistsForSmartQueue(seed); } + return _buildOfflineRelatedArtistsFromSeed( + seed, + providerLabel: seed.provider, + ); } Future> _fetchSpotifyRelatedArtistsForSmartQueue(_SmartQueueArtistSeed seed) async { + return _buildOfflineRelatedArtistsFromSeed( + seed, + providerLabel: _smartQueueSpotifyExtensionId, + ); + } + + Future> _buildOfflineRelatedArtistsFromSeed( + _SmartQueueArtistSeed seed, { + required String providerLabel, + }) async { final seedArtistKey = _normalizeSmartQueueKey(seed.name); if (seedArtistKey.isEmpty) return const []; - final relatedScores = {}; - final relatedNames = {}; + final pool = _buildOfflineTrackPoolForSmartQueue( + maxItems: _smartQueueOfflinePoolMaxItems, + ); + if (pool.isEmpty) return const []; - void addRelatedName(String rawName, double score) { + final candidateScoreByKey = {}; + final candidateNameByKey = {}; + final seedAlbumKeys = {}; + + void addCandidate(String rawName, double score) { final name = rawName.trim(); final key = _normalizeSmartQueueKey(name); if (key.isEmpty || key == seedArtistKey || score <= 0) return; - relatedNames[key] = name; - relatedScores[key] = (relatedScores[key] ?? 0.0) + score; + candidateNameByKey[key] = name; + candidateScoreByKey[key] = (candidateScoreByKey[key] ?? 0) + score; } - try { - final artist = await PlatformBridge.getArtistWithExtension( - _smartQueueSpotifyExtensionId, - seed.id, + for (final track in pool) { + final artists = _extractArtistNamesForSmartQueue(track.artistName); + if (artists.isEmpty) continue; + + final containsSeed = artists.any( + (artistName) => _normalizeSmartQueueKey(artistName) == seedArtistKey, ); - if (artist != null) { - final topTracks = artist['top_tracks'] as List? ?? const []; - for (var index = 0; index < topTracks.length && index < 20; index++) { - final entry = topTracks[index]; - if (entry is! Map) continue; - final map = Map.from(entry); - final artistsText = (map['artists'] ?? map['artist'] ?? '') - .toString() - .trim(); - if (artistsText.isEmpty) continue; - final rankWeight = (1.0 - (index / 18.0)).clamp(0.18, 1.0); - for (final artistName in _extractArtistNamesForSmartQueue( - artistsText, - )) { - addRelatedName(artistName, 0.42 * rankWeight); - } + if (containsSeed) { + for (final artistName in artists) { + final key = _normalizeSmartQueueKey(artistName); + if (key == seedArtistKey) continue; + addCandidate(artistName, 0.85); + } + final albumKey = _normalizeSmartQueueKey(track.albumName); + if (albumKey.isNotEmpty) { + seedAlbumKeys.add(albumKey); } } - } catch (_) {} + } - try { - final searchResults = await PlatformBridge.customSearchWithExtension( - _smartQueueSpotifyExtensionId, - seed.name, - options: { - 'filter': 'artists', - 'limit': 12, - 'offset': 0, - }, - ); - for (var index = 0; index < searchResults.length; index++) { - final map = searchResults[index]; - final itemType = (map['item_type'] ?? '').toString().toLowerCase(); - if (itemType.isNotEmpty && itemType != 'artist') continue; - final id = (map['id'] ?? '').toString().trim(); - final name = (map['name'] ?? '').toString().trim(); - if (name.isEmpty) continue; - final normalizedName = _normalizeSmartQueueKey(name); - if (normalizedName == seedArtistKey || id == seed.id) continue; - - final similarity = _artistNameSimilarity(seed.name, name); - final rankWeight = (1.0 - (index / 12.0)).clamp(0.1, 1.0); - addRelatedName(name, (rankWeight * 0.24) + (similarity * 0.12)); + if (seedAlbumKeys.isNotEmpty) { + for (final track in pool) { + final albumKey = _normalizeSmartQueueKey(track.albumName); + if (albumKey.isEmpty || !seedAlbumKeys.contains(albumKey)) continue; + for (final artistName in _extractArtistNamesForSmartQueue( + track.artistName, + )) { + final key = _normalizeSmartQueueKey(artistName); + if (key == seedArtistKey) continue; + addCandidate(artistName, 0.38); + } } - } catch (_) {} + } - if (relatedScores.isEmpty) return const []; + if (candidateScoreByKey.isEmpty) return const []; final related = <_SmartQueueRelatedArtist>[]; - for (final entry in relatedScores.entries) { + for (final entry in candidateScoreByKey.entries) { related.add( _SmartQueueRelatedArtist( - name: relatedNames[entry.key] ?? entry.key, - provider: _smartQueueSpotifyExtensionId, + name: candidateNameByKey[entry.key] ?? entry.key, + provider: providerLabel, score: entry.value.clamp(0.05, 1.0), ), ); @@ -2632,71 +2734,63 @@ class PlaybackController extends Notifier { return const []; } - try { - final List> artistsRaw; - if (normalizedProvider == 'spotify') { - final response = await PlatformBridge.customSearchWithExtension( - _smartQueueSpotifyExtensionId, - normalizedQuery, - options: { - 'filter': 'artists', - 'limit': min(30, max(4, limit)), - 'offset': 0, - }, - ); - artistsRaw = response - .where( - (item) => - (item['item_type'] ?? 'artist').toString().toLowerCase() == - 'artist', - ) - .toList(growable: false); - } else { - final result = await PlatformBridge.searchDeezerAll( - normalizedQuery, - trackLimit: 1, - artistLimit: limit, - filter: 'artist', - ); - final raw = result['artists'] as List? ?? const []; - artistsRaw = raw - .whereType() - .map((entry) => Map.from(entry)) - .toList(growable: false); - } + final pool = _buildOfflineTrackPoolForSmartQueue( + maxItems: _smartQueueOfflinePoolMaxItems, + ); + if (pool.isEmpty) return const []; - final seeds = <_SmartQueueArtistSeed>[]; - final seen = {}; - for (var index = 0; index < artistsRaw.length; index++) { - final map = artistsRaw[index]; - final id = (map['id'] ?? '').toString().trim(); - final name = (map['name'] ?? '').toString().trim(); - if (id.isEmpty || name.isEmpty) continue; - final key = '$normalizedProvider:${_normalizeSmartQueueKey(id)}'; - if (!seen.add(key)) continue; + final statsByArtist = {}; + for (final track in pool) { + for (final artistName in _extractArtistNamesForSmartQueue( + track.artistName, + )) { + final key = _normalizeSmartQueueKey(artistName); + if (key.isEmpty) continue; + final similarity = _artistNameSimilarity(normalizedQuery, artistName); + if (similarity <= 0.05 && + !key.contains(_normalizeSmartQueueKey(normalizedQuery))) { + continue; + } - final popularity = (map['popularity'] as num?)?.toDouble() ?? 0.0; - final similarity = _artistNameSimilarity(query, name); - final rankScore = (1.0 - (index / max(1, artistsRaw.length))).clamp( - 0.05, - 1.0, - ); - final score = normalizedProvider == 'spotify' - ? (similarity * 0.82) + (rankScore * 0.18) - : (similarity * 0.72) + ((popularity / 100.0) * 0.28); - seeds.add( - _SmartQueueArtistSeed( - id: id, - name: name, - provider: normalizedProvider, - score: score.clamp(0.0, 1.0), - ), - ); + final current = statsByArtist[key]; + if (current == null) { + statsByArtist[key] = _OfflineSmartQueueArtistStats( + name: artistName, + count: 1, + scoreSum: similarity, + ); + } else { + statsByArtist[key] = _OfflineSmartQueueArtistStats( + name: current.name, + count: current.count + 1, + scoreSum: current.scoreSum + similarity, + ); + } } - return seeds; - } catch (_) { - return const []; } + + if (statsByArtist.isEmpty) return const []; + final ranked = <_SmartQueueArtistSeed>[]; + for (final entry in statsByArtist.entries) { + final stats = entry.value; + final frequencyBoost = min(1.0, stats.count / 18.0); + final meanSimilarity = stats.scoreSum / max(1, stats.count); + final score = ((meanSimilarity * 0.78) + (frequencyBoost * 0.22)).clamp( + 0.0, + 1.0, + ); + ranked.add( + _SmartQueueArtistSeed( + id: '$normalizedProvider:${entry.key}', + name: stats.name, + provider: normalizedProvider, + score: score, + ), + ); + } + + ranked.sort((a, b) => b.score.compareTo(a.score)); + return ranked.take(max(1, limit)).toList(growable: false); } double _artistNameSimilarity(String a, String b) { @@ -2898,44 +2992,267 @@ class PlaybackController extends Notifier { String query, { required int trackLimit, }) async { - final response = await PlatformBridge.customSearchWithExtension( - _smartQueueSpotifyExtensionId, + return _searchOfflineTracksForSmartQueue( query, - options: { - 'filter': 'tracks', - 'limit': min(50, max(1, trackLimit)), - 'offset': 0, - }, + trackLimit: trackLimit, + providerHint: 'spotify', ); - return response - .where( - (item) => - (item['item_type'] ?? 'track').toString().toLowerCase() == - 'track', - ) - .toList(growable: false); } Future>> _searchDeezerTracksForSmartQueue( String query, { required int trackLimit, }) async { - final result = await PlatformBridge.searchDeezerAll( + return _searchOfflineTracksForSmartQueue( query, trackLimit: trackLimit, - artistLimit: 0, - filter: 'track', + providerHint: 'deezer', ); - final tracks = result['tracks'] as List? ?? const []; - return tracks - .whereType() - .map((entry) { - final map = Map.from(entry); - map.putIfAbsent('provider_id', () => 'deezer'); - map.putIfAbsent('source', () => 'deezer'); - return map; - }) + } + + Future>> _searchOfflineTracksForSmartQueue( + String query, { + required int trackLimit, + required String providerHint, + }) async { + final normalizedQuery = _normalizeSmartQueueKey(query); + if (normalizedQuery.isEmpty || trackLimit <= 0) return const []; + + final terms = normalizedQuery + .split(RegExp(r'[^a-z0-9]+')) + .where((token) => token.isNotEmpty) .toList(growable: false); + final pool = _buildOfflineTrackPoolForSmartQueue( + maxItems: _smartQueueOfflinePoolMaxItems, + ); + if (pool.isEmpty) return const []; + + final scored = <_OfflineSmartQueueTrackHit>[]; + for (final track in pool) { + var score = _searchScoreForOfflineTrack( + track, + normalizedQuery: normalizedQuery, + terms: terms, + ); + if (score <= 0) continue; + + if (providerHint == 'spotify' && _looksLikeSpotifyTrackId(track.id)) { + score += 0.22; + } else if (providerHint == 'deezer' && + _looksLikeDeezerTrackId(track.id, track.deezerId)) { + score += 0.22; + } + + final artistAffinity = + _smartQueueArtistAffinity[_normalizeSmartQueueKey( + track.artistName, + )] ?? + 0.0; + score += max(0.0, artistAffinity) * 0.25; + score += _smartQueueRandom.nextDouble() * 0.05; + scored.add(_OfflineSmartQueueTrackHit(track: track, score: score)); + } + + if (scored.isEmpty) return const []; + scored.sort((a, b) => b.score.compareTo(a.score)); + final target = max(1, trackLimit); + return scored + .take(target) + .map( + (entry) => _rawMapForOfflineSmartQueueTrack( + entry.track, + providerHint: providerHint, + ), + ) + .toList(growable: false); + } + + List _buildOfflineTrackPoolForSmartQueue({required int maxItems}) { + if (maxItems <= 0) return const []; + + final localItems = [...ref.read(localLibraryProvider).items]; + final historyItems = [...ref.read(downloadHistoryProvider).items]; + localItems.sort((a, b) => b.scannedAt.compareTo(a.scannedAt)); + historyItems.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); + + final pool = []; + final seen = {}; + final perSourceCap = max(40, maxItems ~/ 2); + + void addTrack(Track? track) { + if (track == null) return; + final name = track.name.trim(); + final artist = track.artistName.trim(); + if (name.isEmpty || artist.isEmpty) return; + final key = _trackKeyFromTrack(track); + if (key.isEmpty || !seen.add(key)) return; + pool.add(track); + } + + for (final item in historyItems.take(perSourceCap)) { + addTrack(_trackFromDownloadHistoryForSmartQueue(item)); + } + for (final item in localItems.take(perSourceCap)) { + addTrack(_trackFromLocalLibraryForSmartQueue(item)); + } + + if (pool.length <= maxItems) return pool; + return pool.take(maxItems).toList(growable: false); + } + + Track? _trackFromDownloadHistoryForSmartQueue(DownloadHistoryItem item) { + final path = item.filePath.trim(); + if (path.isEmpty) return null; + final title = item.trackName.trim(); + final artist = item.artistName.trim(); + if (title.isEmpty || artist.isEmpty) return null; + + final spotifyId = (item.spotifyId ?? '').trim(); + final id = spotifyId.isNotEmpty ? spotifyId : 'history:${item.id}'; + return Track( + id: id, + name: title, + artistName: artist, + albumName: item.albumName, + albumArtist: item.albumArtist, + coverUrl: item.coverUrl, + isrc: item.isrc, + duration: max(0, item.duration ?? 0), + trackNumber: item.trackNumber, + discNumber: item.discNumber, + releaseDate: item.releaseDate, + source: 'offline', + ); + } + + Track? _trackFromLocalLibraryForSmartQueue(LocalLibraryItem item) { + final path = item.filePath.trim(); + if (path.isEmpty) return null; + + final title = item.trackName.trim(); + final artist = item.artistName.trim(); + if (title.isEmpty || artist.isEmpty) return null; + + return Track( + id: 'local:${item.id}', + name: title, + artistName: artist, + albumName: item.albumName, + albumArtist: item.albumArtist, + coverUrl: item.coverPath, + isrc: item.isrc, + duration: max(0, item.duration ?? 0), + trackNumber: item.trackNumber, + discNumber: item.discNumber, + releaseDate: item.releaseDate, + source: 'local', + ); + } + + double _searchScoreForOfflineTrack( + Track track, { + required String normalizedQuery, + required List terms, + }) { + final title = _normalizeSmartQueueKey(track.name); + final artist = _normalizeSmartQueueKey(track.artistName); + final album = _normalizeSmartQueueKey(track.albumName); + final full = '$title $artist $album'; + if (full.trim().isEmpty) return 0; + + var score = 0.0; + if (title == normalizedQuery) { + score += 4.2; + } else if (title.startsWith(normalizedQuery)) { + score += 3.5; + } else if (title.contains(normalizedQuery)) { + score += 2.8; + } + + if (artist == normalizedQuery) { + score += 3.4; + } else if (artist.startsWith(normalizedQuery)) { + score += 2.7; + } else if (artist.contains(normalizedQuery)) { + score += 2.0; + } + + if (album == normalizedQuery) { + score += 1.6; + } else if (album.contains(normalizedQuery) && album.isNotEmpty) { + score += 1.0; + } + + var matchedTerms = 0; + for (final term in terms) { + if (term.length < 2) continue; + if (title.contains(term)) { + score += 0.85; + matchedTerms++; + } else if (artist.contains(term)) { + score += 0.72; + matchedTerms++; + } else if (album.contains(term)) { + score += 0.48; + matchedTerms++; + } + } + if (terms.isNotEmpty && matchedTerms == terms.length) { + score += 0.6; + } + return score; + } + + bool _looksLikeSpotifyTrackId(String rawId) { + final id = rawId.trim(); + if (id.isEmpty) return false; + final lowered = id.toLowerCase(); + if (lowered.startsWith('spotify:track:')) return true; + if (lowered.contains('open.spotify.com/track/')) return true; + return RegExp(r'^[a-z0-9]{22}$', caseSensitive: false).hasMatch(id); + } + + bool _looksLikeDeezerTrackId(String rawId, String? deezerId) { + if (deezerId != null && deezerId.trim().isNotEmpty) return true; + final id = rawId.trim(); + if (id.isEmpty) return false; + return RegExp(r'^\d{4,}$').hasMatch(id); + } + + Map _rawMapForOfflineSmartQueueTrack( + Track track, { + required String providerHint, + }) { + final rawId = track.id.trim(); + final map = { + 'id': rawId, + 'name': track.name, + 'artists': track.artistName, + 'artist': track.artistName, + 'album_name': track.albumName, + 'album': track.albumName, + 'album_artist': track.albumArtist, + 'cover_url': track.coverUrl, + 'isrc': track.isrc, + 'duration': track.duration, + 'duration_ms': track.duration > 0 ? track.duration * 1000 : 0, + 'track_number': track.trackNumber, + 'disc_number': track.discNumber, + 'release_date': track.releaseDate, + 'album_type': track.albumType, + 'item_type': 'track', + 'source': track.source ?? 'offline', + 'provider_id': providerHint, + 'deezer_id': track.deezerId, + }; + + if (_looksLikeSpotifyTrackId(rawId)) { + final spotifyId = rawId.toLowerCase().startsWith('spotify:track:') + ? rawId.split(':').last + : rawId; + map['spotify_id'] = spotifyId; + } + return map; } String _resolveSmartQueueSourceLabel(Track track) { @@ -3875,6 +4192,25 @@ class _SmartQueueCandidate { }); } +class _OfflineSmartQueueTrackHit { + final Track track; + final double score; + + const _OfflineSmartQueueTrackHit({required this.track, required this.score}); +} + +class _OfflineSmartQueueArtistStats { + final String name; + final int count; + final double scoreSum; + + const _OfflineSmartQueueArtistStats({ + required this.name, + required this.count, + required this.scoreSum, + }); +} + final playbackProvider = NotifierProvider( PlaybackController.new, ); From bddd733466fec3c78ffa69736933c02ffdc32cfd Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:21:02 +0700 Subject: [PATCH 23/38] fix: trigger smart queue for local play actions --- lib/providers/playback_provider.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 56f4849a..d9c698c4 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -1282,6 +1282,7 @@ class PlaybackController extends Notifier { required String artist, String album = '', String coverUrl = '', + Track? track, }) async { final requestEpoch = _startNewPlayRequest(); _resetPrefetchCycleState(); @@ -1289,6 +1290,15 @@ class PlaybackController extends Notifier { _pendingResumePosition = null; _pendingResumeIndex = null; final uri = _uriFromPath(path); + final fallbackTrack = Track( + id: path, + name: title, + artistName: artist, + albumName: album, + coverUrl: coverUrl.isNotEmpty ? coverUrl : null, + duration: 0, + source: 'local', + ); final item = PlaybackItem( id: path, title: title, @@ -1298,6 +1308,7 @@ class PlaybackController extends Notifier { sourceUri: uri.toString(), isLocal: true, service: 'offline', + track: track ?? fallbackTrack, ); _clearLyricsForTrackChange(upcomingItem: item); From 77d0ac4fce2c7d10de0c599841002499ad14c7ee Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:26:11 +0700 Subject: [PATCH 24/38] fix: prioritize local embedded lyrics before online fetch --- lib/providers/playback_provider.dart | 169 +++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index d9c698c4..60731989 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -1727,6 +1727,16 @@ class PlaybackController extends Notifier { state = state.copyWith(lyricsLoading: true, clearLyrics: true); try { + final localLyrics = await _tryLoadLocalLyricsForItem(item); + if (generation != _lyricsGeneration) return; + if (localLyrics != null) { + _log.d( + 'Lyrics loaded from local source: ${localLyrics.source} (sync=${localLyrics.syncType}, lines=${localLyrics.lines.length}, wordSync=${localLyrics.isWordSynced})', + ); + state = state.copyWith(lyricsLoading: false, lyrics: localLyrics); + return; + } + final result = await PlatformBridge.fetchLyrics( item.id, item.title, @@ -1808,6 +1818,75 @@ class PlaybackController extends Notifier { await _fetchLyricsForItem(item); } + Future _tryLoadLocalLyricsForItem(PlaybackItem item) async { + final localPath = _resolveLocalLyricsLookupPath(item); + if (localPath == null) return null; + + try { + final result = await PlatformBridge.getLyricsLRCWithSource( + item.id, + item.title, + item.artist, + filePath: localPath, + durationMs: item.durationMs, + ); + return _lyricsDataFromLrcLookupResult(result); + } catch (e) { + _log.d('Local lyrics lookup skipped for ${item.id}: $e'); + return null; + } + } + + String? _resolveLocalLyricsLookupPath(PlaybackItem item) { + if (!item.isLocal) return null; + final sourceUri = item.sourceUri.trim(); + if (sourceUri.isEmpty) return null; + if (sourceUri.startsWith('content://')) return sourceUri; + if (sourceUri.startsWith('/')) return sourceUri; + + final uri = Uri.tryParse(sourceUri); + if (uri == null) return null; + if (uri.scheme == 'content') return sourceUri; + if (uri.scheme == 'file') { + try { + return uri.toFilePath(); + } catch (_) { + return uri.path.isNotEmpty ? uri.path : null; + } + } + return null; + } + + LyricsData? _lyricsDataFromLrcLookupResult(Map result) { + final rawLyrics = (result['lyrics'] as String?)?.trim() ?? ''; + final sourceRaw = (result['source'] as String?)?.trim() ?? ''; + final syncTypeRaw = (result['sync_type'] as String?)?.trim().toUpperCase(); + final instrumental = + result['instrumental'] == true || rawLyrics == '[instrumental:true]'; + final source = sourceRaw.isNotEmpty ? sourceRaw : 'Embedded'; + + if (instrumental) { + final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' + ? syncTypeRaw! + : 'UNSYNCED'; + return LyricsData(instrumental: true, source: source, syncType: syncType); + } + if (rawLyrics.isEmpty) return null; + + final parsed = _parseLrcLyrics(rawLyrics); + if (parsed.lines.isEmpty) return null; + final effectiveSyncType = parsed.hasTimedLines ? 'LINE_SYNCED' : 'UNSYNCED'; + final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' + ? syncTypeRaw! + : effectiveSyncType; + return LyricsData( + lines: parsed.lines, + syncType: syncType, + source: source, + isWordSynced: parsed.hasWordSync, + ); + } + /// Parse raw lines from Go backend into [LyricsLine] list. static ({List lines, bool hasWordSync}) _parseLyricsLines( List rawLines, @@ -1857,6 +1936,96 @@ class PlaybackController extends Notifier { return (lines: lines, hasWordSync: hasAnyWordSync); } + static final RegExp _lrcLineTimestampPattern = RegExp( + r'\[(\d{2}):(\d{2})\.(\d{2,3})\]', + ); + static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); + static final RegExp _lrcSpeakerPrefixPattern = RegExp( + r'^(v1|v2):\s*', + caseSensitive: false, + ); + + static ({List lines, bool hasWordSync, bool hasTimedLines}) + _parseLrcLyrics(String lrcText) { + final timed = []; + final unsyncedTexts = []; + var hasAnyWordSync = false; + + for (final rawLine in lrcText.split('\n')) { + final trimmed = rawLine.trim(); + if (trimmed.isEmpty || trimmed == '[instrumental:true]') continue; + + final timestamps = _lrcLineTimestampPattern.allMatches(trimmed).toList(); + if (timestamps.isEmpty) { + if (_lrcMetadataPattern.hasMatch(trimmed)) continue; + final unsynced = _stripInlineTimestamps( + trimmed.replaceFirst(_lrcSpeakerPrefixPattern, ''), + ); + if (unsynced.isNotEmpty) { + unsyncedTexts.add(unsynced); + } + continue; + } + + final timedText = trimmed + .replaceAll(_lrcLineTimestampPattern, '') + .replaceFirst(_lrcSpeakerPrefixPattern, '') + .trim(); + final displayText = _stripInlineTimestamps(timedText); + if (displayText.isEmpty) continue; + + for (final match in timestamps) { + final startMs = _lrcInlineToMs( + match.group(1)!, + match.group(2)!, + match.group(3)!, + ); + final words = _parseInlineWordTimestamps(timedText, startMs); + if (words.isNotEmpty) hasAnyWordSync = true; + timed.add( + LyricsLine( + startMs: startMs, + endMs: startMs + 5000, + text: displayText, + words: words, + ), + ); + } + } + + if (timed.isNotEmpty) { + timed.sort((a, b) => a.startMs.compareTo(b.startMs)); + final normalized = []; + for (var i = 0; i < timed.length; i++) { + final current = timed[i]; + final nextStart = i + 1 < timed.length + ? timed[i + 1].startMs + : current.startMs + 5000; + final endMs = nextStart > current.startMs + ? nextStart + : current.startMs + 5000; + normalized.add( + LyricsLine( + startMs: current.startMs, + endMs: endMs, + text: current.text, + words: current.words, + ), + ); + } + return ( + lines: normalized, + hasWordSync: hasAnyWordSync, + hasTimedLines: true, + ); + } + + final unsynced = unsyncedTexts + .map((text) => LyricsLine(startMs: 0, endMs: 0, text: text)) + .toList(growable: false); + return (lines: unsynced, hasWordSync: false, hasTimedLines: false); + } + /// Parse inline `` timestamps in enhanced LRC word-by-word format. static List _parseInlineWordTimestamps( String text, From 54a7b6b5685fe1cc24339bb48c373ba0839a77ad Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:27:30 +0700 Subject: [PATCH 25/38] fix: load lyrics from sidecar lrc before online lookup --- go_backend/metadata.go | 60 +++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/go_backend/metadata.go b/go_backend/metadata.go index e0034dbd..29ab2d02 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -545,38 +545,60 @@ func ExtractLyrics(filePath string) (string, error) { lower := strings.ToLower(filePath) if strings.HasSuffix(lower, ".flac") { - return extractLyricsFromFlac(filePath) + lyrics, err := extractLyricsFromFlac(filePath) + if err == nil && strings.TrimSpace(lyrics) != "" { + return lyrics, nil + } + return extractLyricsFromSidecarLRC(filePath) } if strings.HasSuffix(lower, ".mp3") { meta, err := ReadID3Tags(filePath) - if err != nil || meta == nil { - return "", fmt.Errorf("no lyrics found in file") + if err == nil && meta != nil { + if strings.TrimSpace(meta.Lyrics) != "" { + return meta.Lyrics, nil + } + if looksLikeEmbeddedLyrics(meta.Comment) { + return meta.Comment, nil + } } - if strings.TrimSpace(meta.Lyrics) != "" { - return meta.Lyrics, nil - } - if looksLikeEmbeddedLyrics(meta.Comment) { - return meta.Comment, nil - } - return "", fmt.Errorf("no lyrics found in file") + return extractLyricsFromSidecarLRC(filePath) } if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") { meta, err := ReadOggVorbisComments(filePath) - if err != nil || meta == nil { - return "", fmt.Errorf("no lyrics found in file") - } - if strings.TrimSpace(meta.Lyrics) != "" { - return meta.Lyrics, nil - } - if looksLikeEmbeddedLyrics(meta.Comment) { - return meta.Comment, nil + if err == nil && meta != nil { + if strings.TrimSpace(meta.Lyrics) != "" { + return meta.Lyrics, nil + } + if looksLikeEmbeddedLyrics(meta.Comment) { + return meta.Comment, nil + } } + return extractLyricsFromSidecarLRC(filePath) + } + + return extractLyricsFromSidecarLRC(filePath) +} + +func extractLyricsFromSidecarLRC(filePath string) (string, error) { + ext := filepath.Ext(filePath) + base := strings.TrimSuffix(filePath, ext) + if strings.TrimSpace(base) == "" { return "", fmt.Errorf("no lyrics found in file") } - return "", fmt.Errorf("unsupported file format for lyrics extraction") + lrcPath := base + ".lrc" + data, err := os.ReadFile(lrcPath) + if err != nil { + return "", fmt.Errorf("no lyrics found in file") + } + + lyrics := strings.TrimSpace(string(data)) + if lyrics == "" { + return "", fmt.Errorf("no lyrics found in file") + } + return lyrics, nil } func extractLyricsFromFlac(filePath string) (string, error) { From a07c12545490fb12b29a7b9c5eed0f6c978c8e8b Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:30:10 +0700 Subject: [PATCH 26/38] feat: update collection actions for offline-first playback --- lib/screens/album_screen.dart | 123 ++++++++---- lib/screens/artist_screen.dart | 185 +++++++++++++----- lib/screens/playlist_screen.dart | 123 ++++++++---- .../track_collection_quick_actions.dart | 184 ++++++++++++++--- 4 files changed, 450 insertions(+), 165 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 5cd0d030..a5411ae3 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -8,6 +8,7 @@ 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/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -761,7 +762,12 @@ class _AlbumTrackItem extends ConsumerWidget { final isInHistory = ref.watch( downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; }), ); @@ -861,13 +867,7 @@ class _AlbumTrackItem extends ConsumerWidget { ], ), trailing: TrackCollectionQuickActions(track: track), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), + onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, @@ -882,47 +882,84 @@ class _AlbumTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, { required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, }) async { if (isQueued) return; - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(context, ref); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - onDownload(); } + + Future _playLocalIfAvailable( + BuildContext context, + WidgetRef ref, + ) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; + } + + return false; + } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 420ee857..43cfa2eb 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -11,6 +11,7 @@ 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/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; @@ -1243,9 +1244,17 @@ class _ArtistScreenState extends ConsumerState { ); final isInHistory = ref.watch( - downloadHistoryProvider.select( - (state) => state.isDownloaded(track.id), - ), + downloadHistoryProvider.select((state) { + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && + isrc.isNotEmpty && + state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != + null; + }), ); final showLocalLibraryIndicator = ref.watch( @@ -1268,12 +1277,7 @@ class _ArtistScreenState extends ConsumerState { final isQueued = queueItem != null; return InkWell( - onTap: () => _handlePopularTrackTap( - track, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, @@ -1344,17 +1348,61 @@ class _ArtistScreenState extends ConsumerState { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (track.albumName.isNotEmpty) - ClickableAlbumName( - albumName: track.albumName, - albumId: track.albumId, - artistName: track.artistName, - coverUrl: track.coverUrl, - extensionId: widget.extensionId, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (track.albumName.isNotEmpty || + isInLocalLibrary || + isInHistory) + Row( + children: [ + if (track.albumName.isNotEmpty) + Expanded( + child: ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, + extensionId: widget.extensionId, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isInLocalLibrary || isInHistory) ...[ + if (track.albumName.isNotEmpty) + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], ), ], ), @@ -1369,51 +1417,82 @@ class _ArtistScreenState extends ConsumerState { } /// Handle tap on popular track item - void _handlePopularTrackTap( - Track track, { - required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, - }) async { + void _handlePopularTrackTap(Track track, {required bool isQueued}) async { if (isQueued) return; - if (isInLocalLibrary) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(track); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); + _downloadTrack(track); + } + + Future _playLocalIfAvailable(Track track) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + if (historyItem != null) { final exists = await fileExists(historyItem.filePath); if (exists) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; } + historyNotifier.removeFromHistory(historyItem.id); } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; } - _downloadTrack(track); + return false; } void _downloadTrack(Track track) { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 8d43dc90..d005678d 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; 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'; @@ -631,7 +632,12 @@ class _PlaylistTrackItem extends ConsumerWidget { final isInHistory = ref.watch( downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; }), ); @@ -742,13 +748,7 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ), trailing: TrackCollectionQuickActions(track: track), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), + onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, @@ -763,47 +763,84 @@ class _PlaylistTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, { required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, }) async { if (isQueued) return; - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(context, ref); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - onDownload(); } + + Future _playLocalIfAvailable( + BuildContext context, + WidgetRef ref, + ) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; + } + + return false; + } } diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 601fac9c..c45649f1 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -5,8 +7,11 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -171,9 +176,20 @@ class _TrackOptionsSheet extends ConsumerWidget { // Action items (matches _QualityOption style) _OptionTile( icon: Icons.download_rounded, - title: context.l10n.downloadTitle, + title: 'Download & Play', onTap: () async { Navigator.pop(context); + final playedLocal = await _playLocalIfAvailable( + container, + rootContext, + ); + if (playedLocal) { + return; + } + if (!rootContext.mounted) { + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( rootContext, @@ -181,35 +197,19 @@ class _TrackOptionsSheet extends ConsumerWidget { artistName: track.artistName, coverUrl: track.coverUrl, onSelect: (quality, service) { - container - .read(downloadQueueProvider.notifier) - .addToQueue( - track, - service, - qualityOverride: quality, - ); - ScaffoldMessenger.of(rootContext).showSnackBar( - SnackBar( - content: Text( - rootContext.l10n.snackbarAddedToQueue(track.name), - ), - ), + _enqueueDownloadAndAutoPlay( + container: container, + context: rootContext, + service: service, + quality: quality, ); }, ); } else { - container - .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); - if (!rootContext.mounted) { - return; - } - ScaffoldMessenger.of(rootContext).showSnackBar( - SnackBar( - content: Text( - rootContext.l10n.snackbarAddedToQueue(track.name), - ), - ), + _enqueueDownloadAndAutoPlay( + container: container, + context: rootContext, + service: settings.defaultService, ); } }, @@ -282,6 +282,138 @@ class _TrackOptionsSheet extends ConsumerWidget { ), ); } + + Future _playLocalIfAvailable( + ProviderContainer container, + BuildContext context, + ) async { + final localState = container.read(localLibraryProvider); + final historyState = container.read(downloadHistoryProvider); + final historyNotifier = container.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await container + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await container + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; + } + + return false; + } + + void _enqueueDownloadAndAutoPlay({ + required ProviderContainer container, + required BuildContext context, + required String service, + String? quality, + }) { + container + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + unawaited(_waitForDownloadedFileAndPlay(container, context)); + } + + Future _waitForDownloadedFileAndPlay( + ProviderContainer container, + BuildContext context, + ) async { + const maxAttempts = 180; // up to ~3 minutes + for (var i = 0; i < maxAttempts; i++) { + final item = _findHistoryMatch(container); + if (item != null && await fileExists(item.filePath)) { + try { + await container + .read(playbackProvider.notifier) + .playLocalPath( + path: item.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile('$e')), + ), + ); + } + } + return; + } + await Future.delayed(const Duration(seconds: 1)); + } + } + + DownloadHistoryItem? _findHistoryMatch(ProviderContainer container) { + final historyState = container.read(downloadHistoryProvider); + final historyNotifier = container.read(downloadHistoryProvider.notifier); + final isrc = track.isrc?.trim(); + + return historyNotifier.getBySpotifyId(track.id) ?? + ((isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null) ?? + historyState.findByTrackAndArtist(track.name, track.artistName); + } } /// Styled like _QualityOption in download_service_picker.dart From b3771f34884fc197f758d5ab5f4efc0d6c97ca3d Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:31:49 +0700 Subject: [PATCH 27/38] fix: disable automatic spotify-web install during setup --- lib/screens/setup_screen.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 77456bd8..ede9017d 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -6,7 +6,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -401,9 +400,6 @@ class _SetupScreenState extends ConsumerState { } ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - await ref - .read(extensionProvider.notifier) - .ensureSpotifyWebExtensionReady(); ref.read(settingsProvider.notifier).setFirstLaunchComplete(); if (mounted) context.go('/tutorial'); From 96d11b1d7d90c719bce4eec4b6a797471630f38b Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:38:45 +0700 Subject: [PATCH 28/38] feat: add external player mode for local library playback --- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/playback_provider.dart | 83 +++++++++++++++++++ lib/providers/settings_provider.dart | 6 ++ .../settings/options_settings_page.dart | 78 +++++++++++++++++ 5 files changed, 173 insertions(+) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 4b4a10b3..97c29817 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -12,6 +12,7 @@ class AppSettings { final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; final bool autoSkipUnavailableTracks; + final String playerMode; // 'internal' or 'external' final bool smartQueueEnabled; // Enable smart curated autoplay queue final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedLyrics; @@ -92,6 +93,7 @@ class AppSettings { this.downloadTreeUri = '', this.autoFallback = true, this.autoSkipUnavailableTracks = true, + this.playerMode = 'internal', this.smartQueueEnabled = true, this.embedMetadata = true, this.embedLyrics = true, @@ -160,6 +162,7 @@ class AppSettings { String? downloadTreeUri, bool? autoFallback, bool? autoSkipUnavailableTracks, + String? playerMode, bool? smartQueueEnabled, bool? embedMetadata, bool? embedLyrics, @@ -222,6 +225,7 @@ class AppSettings { autoFallback: autoFallback ?? this.autoFallback, autoSkipUnavailableTracks: autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks, + playerMode: playerMode ?? this.playerMode, smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled, embedMetadata: embedMetadata ?? this.embedMetadata, embedLyrics: embedLyrics ?? this.embedLyrics, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 853d34aa..b484ee15 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true, + playerMode: json['playerMode'] as String? ?? 'internal', smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true, @@ -93,6 +94,7 @@ Map _$AppSettingsToJson( 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, 'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks, + 'playerMode': instance.playerMode, 'smartQueueEnabled': instance.smartQueueEnabled, 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 60731989..41eca8b8 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -1647,6 +1648,16 @@ class PlaybackController extends Notifier { !_isPlayRequestCurrent(expectedRequestEpoch)) { return; } + + final handledByExternal = await _tryPlayWithExternalPlayerIfConfigured( + uri: uri, + item: item, + expectedRequestEpoch: expectedRequestEpoch, + ); + if (handledByExternal) { + return; + } + final sourceUrl = uri.toString(); await FFmpegService.activatePreparedNativeDashManifest(sourceUrl); if (!FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { @@ -1719,6 +1730,78 @@ class PlaybackController extends Notifier { } } + Future _tryPlayWithExternalPlayerIfConfigured({ + required Uri uri, + required PlaybackItem item, + int? expectedRequestEpoch, + }) async { + final settings = ref.read(settingsProvider); + if (settings.playerMode != 'external') return false; + if (!item.isLocal) return false; + + final externalPath = _externalPathFromPlaybackUri(uri); + if (externalPath == null || externalPath.isEmpty) return false; + + _log.d('Opening with external player: $externalPath'); + _updateMediaItemNotification(item); + + try { + await openFile(externalPath); + if (expectedRequestEpoch != null && + !_isPlayRequestCurrent(expectedRequestEpoch)) { + return true; + } + state = state.copyWith( + currentItem: item, + isLoading: false, + isBuffering: false, + isPlaying: false, + seekSupported: false, + position: Duration.zero, + bufferedPosition: Duration.zero, + duration: _fallbackDurationForItem(item), + clearError: true, + ); + _syncServicePlaybackState(ProcessingState.idle, false); + unawaited(_savePlaybackSnapshot()); + return true; + } catch (e) { + if (expectedRequestEpoch != null && + !_isPlayRequestCurrent(expectedRequestEpoch)) { + return true; + } + _log.w('External player open failed: $e'); + state = state.copyWith( + isLoading: false, + isBuffering: false, + isPlaying: false, + ); + _setPlaybackError( + 'Failed to open in external player: $e', + type: 'external_player_failed', + ); + return true; + } + } + + String? _externalPathFromPlaybackUri(Uri uri) { + if (uri.scheme == 'content') { + return uri.toString(); + } + if (uri.scheme == 'file') { + try { + return uri.toFilePath(); + } catch (_) { + return uri.path.isNotEmpty ? uri.path : null; + } + } + if (!uri.hasScheme) { + final asString = uri.toString().trim(); + return asString.isNotEmpty ? asString : null; + } + return null; + } + // ─── Lyrics fetching + parsing ─────────────────────────────────────────── Future _fetchLyricsForItem(PlaybackItem item) async { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 18a6a9f0..7bad99bc 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -284,6 +284,12 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setPlayerMode(String mode) { + final normalized = mode == 'external' ? 'external' : 'internal'; + state = state.copyWith(playerMode: normalized); + _saveSettings(); + } + void setSmartQueueEnabled(bool enabled) { state = state.copyWith(smartQueueEnabled: enabled); _saveSettings(); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index dbd4669c..6a33934c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -167,6 +167,16 @@ class OptionsSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setAutoSkipUnavailableTracks(v), ), + SettingsItem( + icon: Icons.headphones, + title: 'Music Player', + subtitle: _playerModeLabel(settings.playerMode), + onTap: () => _showPlayerModePicker( + context, + ref, + settings.playerMode, + ), + ), SettingsSwitchItem( icon: Icons.queue_music_rounded, title: context.l10n.settingsSmartQueueTitle, @@ -318,6 +328,74 @@ class OptionsSettingsPage extends ConsumerWidget { ); } + String _playerModeLabel(String mode) { + if (mode == 'external') { + return 'External app (Poweramp, etc.)'; + } + return 'Internal player'; + } + + void _showPlayerModePicker( + BuildContext context, + WidgetRef ref, + String currentMode, + ) { + showModalBottomSheet( + context: context, + useRootNavigator: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 12), + ListTile( + leading: const Icon(Icons.play_circle_outline), + title: const Text('Internal Player'), + subtitle: const Text('Use built-in app playback and queue'), + trailing: currentMode == 'internal' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref.read(settingsProvider.notifier).setPlayerMode('internal'); + Navigator.pop(sheetContext); + }, + ), + ListTile( + leading: const Icon(Icons.open_in_new), + title: const Text('External Player'), + subtitle: const Text( + 'Open songs with apps like Poweramp, Musicolet, etc.', + ), + trailing: currentMode == 'external' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref.read(settingsProvider.notifier).setPlayerMode('external'); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + void _showClearHistoryDialog( BuildContext context, WidgetRef ref, From 40c3c73bfd968122a1a16fe86ef0eafcf04614a8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:42:15 +0700 Subject: [PATCH 29/38] fix: hide internal player UI when external mode is active --- lib/providers/playback_provider.dart | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 41eca8b8..72d98f45 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -378,6 +378,17 @@ class PlaybackController extends Notifier { } }); + ref.listen(settingsProvider.select((s) => s.playerMode), ( + previous, + next, + ) { + if (previous == next) return; + if (next != 'external') return; + final current = state.currentItem; + if (current == null || !current.isLocal) return; + unawaited(dismissPlayer()); + }); + _subscriptions.add( _player.playerStateStream.listen((playerState) { final playing = playerState.playing; @@ -1746,21 +1757,28 @@ class PlaybackController extends Notifier { _updateMediaItemNotification(item); try { + await FFmpegService.stopLiveDecryptedStream(); + await FFmpegService.stopNativeDashManifestPlayback(); + await _player.stop(); await openFile(externalPath); if (expectedRequestEpoch != null && !_isPlayRequestCurrent(expectedRequestEpoch)) { return true; } state = state.copyWith( - currentItem: item, + clearCurrentItem: true, + queue: const [], + currentIndex: -1, isLoading: false, isBuffering: false, isPlaying: false, seekSupported: false, position: Duration.zero, bufferedPosition: Duration.zero, - duration: _fallbackDurationForItem(item), + duration: Duration.zero, clearError: true, + clearLyrics: true, + lyricsLoading: false, ); _syncServicePlaybackState(ProcessingState.idle, false); unawaited(_savePlaybackSnapshot()); From bfd769b349da2f49ee903325a9baae3211ae6b4f Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 15:05:14 +0700 Subject: [PATCH 30/38] fix(library): exclude downloaded tracks from local scan reliably --- lib/providers/local_library_provider.dart | 64 +++++++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 30e8500b..afa25f66 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -198,7 +198,7 @@ class LocalLibraryNotifier extends Notifier { if (raw.isEmpty) return const {}; final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw; - final keys = {cleaned}; + final keys = {}; void addNormalized(String value) { final trimmed = value.trim(); @@ -217,18 +217,42 @@ class LocalLibraryNotifier extends Notifier { keys.add(decoded.toLowerCase()); } catch (_) {} } + + Uri? parsed; + try { + parsed = Uri.parse(trimmed); + } catch (_) {} + + if (parsed != null && parsed.hasScheme) { + final noQueryOrFragment = parsed.replace(query: null, fragment: null); + keys.add(noQueryOrFragment.toString()); + keys.add(noQueryOrFragment.toString().toLowerCase()); + + if (parsed.scheme == 'file') { + try { + final fileOnly = parsed.toFilePath(); + if (fileOnly.isNotEmpty) { + keys.add(fileOnly); + keys.add(fileOnly.toLowerCase()); + if (fileOnly.contains('\\')) { + final slash = fileOnly.replaceAll('\\', '/'); + keys.add(slash); + keys.add(slash.toLowerCase()); + } + } + } catch (_) {} + } + } else if (trimmed.startsWith('/')) { + try { + final asFileUri = Uri.file(trimmed).toString(); + keys.add(asFileUri); + keys.add(asFileUri.toLowerCase()); + } catch (_) {} + } } addNormalized(cleaned); - if (cleaned.startsWith('content://')) { - try { - final uri = Uri.parse(cleaned); - addNormalized(uri.toString()); - addNormalized(uri.replace(query: null, fragment: null).toString()); - } catch (_) {} - } - return keys; } @@ -345,7 +369,11 @@ class LocalLibraryNotifier extends Notifier { _log.i('Skipped $skippedDownloads files already in download history'); } - await _db.upsertBatch(items.map((e) => e.toJson()).toList()); + // Full scan should replace library index entirely. + await _db.clearAll(); + if (items.isNotEmpty) { + await _db.upsertBatch(items.map((e) => e.toJson()).toList()); + } final now = DateTime.now(); try { @@ -437,10 +465,24 @@ class LocalLibraryNotifier extends Notifier { final currentByPath = { for (final item in state.items) item.filePath: item, }; + final existingDownloadedPaths = []; + currentByPath.removeWhere((path, _) { + final shouldExclude = _isDownloadedPath(path, downloadedPathKeys); + if (shouldExclude) { + existingDownloadedPaths.add(path); + } + return shouldExclude; + }); + if (existingDownloadedPaths.isNotEmpty) { + final removed = await _db.deleteByPaths(existingDownloadedPaths); + _log.i( + 'Removed $removed downloaded tracks already present in local library index', + ); + } // Upsert new/modified items (excluding downloaded files) final updatedItems = []; - int skippedDownloads = 0; + int skippedDownloads = existingDownloadedPaths.length; if (scannedList.isNotEmpty) { for (final json in scannedList) { final map = json as Map; From 4747119a7fefea47a99cda1f6d7eb36649d42341 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 15:05:16 +0700 Subject: [PATCH 31/38] fix(playback): prevent internal mini-player flash in external mode --- lib/providers/playback_provider.dart | 77 ++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 72d98f45..90863db5 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -914,11 +914,17 @@ class PlaybackController extends Notifier { bool _isPlayRequestCurrent(int epoch) => epoch == _playRequestEpoch; - void _clearLyricsForTrackChange({PlaybackItem? upcomingItem}) { + void _clearLyricsForTrackChange({ + PlaybackItem? upcomingItem, + bool updateCurrentItem = true, + }) { // Invalidate any in-flight lyrics fetch from previous track. _lyricsGeneration++; state = state.copyWith( - currentItem: upcomingItem ?? state.currentItem, + clearCurrentItem: !updateCurrentItem, + currentItem: updateCurrentItem + ? (upcomingItem ?? state.currentItem) + : null, lyricsLoading: false, clearLyrics: true, ); @@ -1323,7 +1329,30 @@ class PlaybackController extends Notifier { track: track ?? fallbackTrack, ); - _clearLyricsForTrackChange(upcomingItem: item); + final routeToExternal = _shouldRouteToExternalPlayer(item); + _clearLyricsForTrackChange( + upcomingItem: item, + updateCurrentItem: !routeToExternal, + ); + + if (routeToExternal) { + state = state.copyWith( + clearCurrentItem: true, + queue: const [], + currentIndex: -1, + isLoading: false, + isBuffering: false, + isPlaying: false, + seekSupported: false, + position: Duration.zero, + bufferedPosition: Duration.zero, + duration: Duration.zero, + clearError: true, + ); + unawaited(_savePlaybackSnapshot()); + await _setSourceAndPlay(uri, item, expectedRequestEpoch: requestEpoch); + return; + } // Replacing single-track playback should also replace queue to avoid stale UI. state = state.copyWith( @@ -1588,20 +1617,31 @@ class PlaybackController extends Notifier { _trackKeyFromPlaybackItem(item)) { _rememberRecentPlayed(previousItem); } - _clearLyricsForTrackChange(upcomingItem: item); + final routeToExternal = _shouldRouteToExternalPlayer(item); + _clearLyricsForTrackChange( + upcomingItem: item, + updateCurrentItem: !routeToExternal, + ); state = state.copyWith( currentIndex: index, - currentItem: item, - isLoading: true, - isBuffering: true, + clearCurrentItem: routeToExternal, + currentItem: routeToExternal ? null : item, + isLoading: routeToExternal ? false : true, + isBuffering: routeToExternal ? false : true, isPlaying: false, - seekSupported: _inferSeekSupportedForQueueItem(item), - position: - pendingResumePosition != null && pendingResumePosition > Duration.zero - ? pendingResumePosition - : Duration.zero, + seekSupported: routeToExternal + ? false + : _inferSeekSupportedForQueueItem(item), + position: routeToExternal + ? Duration.zero + : (pendingResumePosition != null && + pendingResumePosition > Duration.zero + ? pendingResumePosition + : Duration.zero), bufferedPosition: Duration.zero, - duration: _fallbackDurationForItem(item), + duration: routeToExternal + ? Duration.zero + : _fallbackDurationForItem(item), clearError: true, ); await _savePlaybackSnapshot(); @@ -1630,7 +1670,7 @@ class PlaybackController extends Notifier { expectedRequestEpoch: requestEpoch, ); if (!_isPlayRequestCurrent(requestEpoch) || - state.currentIndex != index) { + (!routeToExternal && state.currentIndex != index)) { return; } _clearPendingResumeForIndex(index); @@ -1746,9 +1786,7 @@ class PlaybackController extends Notifier { required PlaybackItem item, int? expectedRequestEpoch, }) async { - final settings = ref.read(settingsProvider); - if (settings.playerMode != 'external') return false; - if (!item.isLocal) return false; + if (!_shouldRouteToExternalPlayer(item)) return false; final externalPath = _externalPathFromPlaybackUri(uri); if (externalPath == null || externalPath.isEmpty) return false; @@ -1802,6 +1840,11 @@ class PlaybackController extends Notifier { } } + bool _shouldRouteToExternalPlayer(PlaybackItem item) { + final settings = ref.read(settingsProvider); + return settings.playerMode == 'external' && item.isLocal; + } + String? _externalPathFromPlaybackUri(Uri uri) { if (uri.scheme == 'content') { return uri.toString(); From 98abaf6635236cc691640a467dbf0205fa1331b4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 2 Mar 2026 22:18:53 +0700 Subject: [PATCH 32/38] =?UTF-8?q?v3.7.0:=20roll=20back=20from=20v4,=20remo?= =?UTF-8?q?ve=20internal=20player=20=E2=80=94=20v3=20is=20already=20comple?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version rolled back from v4.x to v3.7.0. After extensive work on v4's internal streaming engine, smart queue, DASH pipeline, and media controls, we realized v3 was already feature-complete. Adding more big features only made maintenance increasingly difficult and the developer's life miserable. Stripped back to what works: external player only, cleaner codebase, sustainable long-term. - Remove just_audio, audio_service, audio_session and entire internal playback engine (smart queue, notification, shuffle/repeat, prefetch) - Remove PlaybackItem model, MiniPlayerBar widget, notification drawables - Remove playerMode setting (external-only now) - Migrate MainActivity from AudioServiceFragmentActivity to FlutterFragmentActivity - Migrate Qobuz to MusicDL API - Update changelog with v3.7.0 rollback explanation --- CHANGELOG.md | 120 +- android/app/proguard-rules.pro | 10 - .../kotlin/com/zarz/spotiflac/MainActivity.kt | 19 +- .../main/res/drawable/ic_stat_favorite.xml | 9 - .../res/drawable/ic_stat_favorite_border.xml | 9 - android/app/src/main/res/raw/keep.xml | 3 - go_backend/qobuz.go | 336 +- go_backend/qobuz_test.go | 88 + lib/constants/app_info.dart | 4 +- lib/l10n/app_localizations.dart | 312 -- lib/l10n/app_localizations_de.dart | 173 - lib/l10n/app_localizations_en.dart | 173 - lib/l10n/app_localizations_es.dart | 180 - lib/l10n/app_localizations_fr.dart | 173 - lib/l10n/app_localizations_hi.dart | 173 - lib/l10n/app_localizations_id.dart | 182 - lib/l10n/app_localizations_ja.dart | 172 - lib/l10n/app_localizations_ko.dart | 172 - lib/l10n/app_localizations_nl.dart | 173 - lib/l10n/app_localizations_pt.dart | 180 - lib/l10n/app_localizations_ru.dart | 173 - lib/l10n/app_localizations_tr.dart | 173 - lib/l10n/app_localizations_zh.dart | 184 - lib/l10n/arb/app_de.arb | 4 +- lib/l10n/arb/app_en.arb | 114 +- lib/l10n/arb/app_es.arb | 4 +- lib/l10n/arb/app_es_ES.arb | 4 +- lib/l10n/arb/app_fr.arb | 4 +- lib/l10n/arb/app_hi.arb | 4 +- lib/l10n/arb/app_id.arb | 110 - lib/l10n/arb/app_ja.arb | 4 +- lib/l10n/arb/app_ko.arb | 4 +- lib/l10n/arb/app_nl.arb | 4 +- lib/l10n/arb/app_pt.arb | 4 +- lib/l10n/arb/app_pt_PT.arb | 4 +- lib/l10n/arb/app_ru.arb | 4 +- lib/l10n/arb/app_tr.arb | 4 +- lib/l10n/arb/app_zh.arb | 4 +- lib/l10n/arb/app_zh_CN.arb | 4 +- lib/l10n/arb/app_zh_TW.arb | 4 +- lib/models/playback_item.dart | 91 - lib/models/settings.dart | 15 +- lib/models/settings.g.dart | 6 - lib/providers/playback_provider.dart | 4497 +---------------- lib/providers/settings_provider.dart | 16 - lib/screens/library_tracks_folder_screen.dart | 191 +- lib/screens/main_shell.dart | 30 +- lib/screens/settings/about_page.dart | 6 + lib/screens/settings/donate_page.dart | 61 +- .../settings/options_settings_page.dart | 102 - lib/services/platform_bridge.dart | 4 + lib/services/update_checker.dart | 15 + lib/widgets/mini_player_bar.dart | 2040 -------- .../track_collection_quick_actions.dart | 183 - pubspec.lock | 56 - pubspec.yaml | 5 +- 56 files changed, 687 insertions(+), 10106 deletions(-) delete mode 100644 android/app/src/main/res/drawable/ic_stat_favorite.xml delete mode 100644 android/app/src/main/res/drawable/ic_stat_favorite_border.xml delete mode 100644 android/app/src/main/res/raw/keep.xml delete mode 100644 lib/models/playback_item.dart delete mode 100644 lib/widgets/mini_player_bar.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 54308e14..3b7c758b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,105 +1,43 @@ # Changelog -## [4.0.1] - 2026-02-26 +## [3.7.0] - 2026-03-04 -### Added -- **Clickable Metadata Navigation**: Added reusable `ClickableArtistName` and `ClickableAlbumName` -- **Love Action in Media Notification**: Added custom notification action (`toggle_love`) with new Android favorite/favorite-border status icons +Hey everyone, thank you so much for sticking with SpotiFLAC Mobile. + +Starting from this release, we're rolling the version back from **v4.x to v3.x**. + +### Removed + +- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player. +- **PlaybackItem Model** — No longer needed without internal playback. +- **MiniPlayerBar Widget** — Removed the in-app mini player UI. +- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file. +- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode. +- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch. ### Changed -- **Track Metadata Model Expansion**: `Track` now carries `artistId` and `albumId`, propagated across search, queue, playback, CSV import, and extension mapping flows -- **Full-Screen Player UX**: Top bar now supports swipe-down dismiss; artist/album text is now tappable; and in-player love toggle is available next to track metadata -- **Playlist Picker Flow Refactor**: Reworked playlist picker sheet into stateful multi-select flow with explicit Done action and improved create-playlist handling -- **CSV Import Interaction Flow**: Added single-flight import guard, more reliable progress dialog lifecycle, and safer local navigator usage -- **Amazon API**: Amazon metadata fetch `amzn.afkarxyz.fun` -- **Qobuz URL Resolution Strategy**: Removed legacy/Jumo fallback path; now uses standard API pool (deeb) -- **Update Checker Asset Targeting**: Update selection now prioritizes arm64/universal assets only -- **Donate Page Supporters**: Updated highlighted donor/supporter list entries +- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`). +- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player. +- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules. +- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API). -### Fixed +### Note +There are three main reasons behind this decision: -- **FLAC External Lyrics Output**: External `.lrc` writing now works consistently for lyrics mode `external`/`both`, with SAF conversion paths avoiding duplicate writes -- **Loved-State Notification Sync**: Playback notification controls now refresh correctly when loved state changes -- **Queue Selection Touch Handling**: Selection overlays/check indicators no longer block tap gestures in queue and playlist selection modes -- **Vorbis-to-ID3 Tag Mapping Robustness**: FFmpeg metadata conversion now normalizes keys and handles aliases like `TRCK` and `TPOS` -- **Nested Dialog Navigation Safety**: Adjusted dialog navigator scope in CSV import and track-delete flows to prevent navigator mismatch issues -- **Artist/Album Routing Reliability**: Track metadata routing now reuses resolved artist/album IDs across album/artist/home/search/queue/player surfaces -- **Release Workflow Go Toolchain**: Pinned CI release workflow Go version to `1.25.7` for consistent build behavior + 1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms. + + 2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone. + +**Still want online playback? Check out these services:** +- [DabMusic](https://dabmusic.xyz) +- [SquidWTF](https://tidal.squid.wtf) + +Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you! --- -## [4.0.0] - 2026-02-22 - -> **Major update warning:** This release introduces a large streaming-focused refactor and broad cross-app behavior changes. -> -> **Diff scope (`cdc583678558223ecbb552176b53727d303ae218..HEAD`):** 121 files changed, 28,354 insertions(+), 4,598 deletions(-). - -### Added - -- **End-to-End Streaming Mode**: Full streaming playback flow with full-screen player, synced lyrics, media controls, and queue-aware tap behavior across album, artist, playlist, home, and search screens -- **Smart Queue System**: ML-based queue auto-curation with related artist discovery, plus a dedicated playback queue view -- **DASH Streaming Pipeline**: Native DASH manifest playback support with local proxy integration and FFmpeg tunnel fallback for unsupported paths -- **Playback State Persistence**: Player state and queue continuity restored across app restarts -- **Adaptive Playback Engine**: EventChannel-driven playback/progress updates (replacing polling) and adaptive prefetch behavior -- **Queue Reliability Controls**: New auto-skip unavailable tracks option during queue playback -- **Player Quick Action**: New download button in full-screen player top bar -- **Metadata Control**: New global master switch for embed metadata behavior -- **Setup Flow Update**: Initial setup now prioritizes mode selection instead of Spotify API setup -- **Library Workflow Expansion**: Playlist-first library redesign, drag-and-drop categorization, folder multi-select, and batch playlist picker flows -- **SongLink Region Setting**: Region selection support for metadata/linking behavior -- **Track Interaction UX**: Long-press context menus for track actions across major collection screens -- **Batch Tools**: Multi-select share, batch convert, and batch re-enrich improvements for downloaded/local/queue workflows - -### Changed - -- **Global Mode-Driven Actions**: Interaction mode now drives behavior app-wide (download-oriented vs streaming-oriented actions) -- **UI Redesign and Responsiveness**: Full-screen cover/parallax rollout and responsive fixes for filter sheets and full-screen player in small screens/landscape -- **Performance Optimizations**: Granular Riverpod consumers, selective provider watching, computation caching, debounced extension storage writes, and lifecycle cleanups -- **Lyrics Loading Strategy**: Lyrics are now lazy-loaded only when the lyrics view is visible -- **Persistence Backend Refactor**: Core persistence paths migrated to SQLite-backed stores for app state and library collections -- **Shared Code Refactor**: Duplicated logic extracted into shared Dart/Go utilities for cleaner boundaries and maintainability - -### Fixed - -- **iOS Build Compatibility**: Resolved `RepeatMode` naming collision with Flutter SDK symbols -- **Playback Completion Handling**: Fixed track completion restart issues and queue-end completion synchronization -- **Streaming Stability**: Added guards for playback race conditions during queue/stream state transitions -- **Provider I/O Safety**: Improved Android/Go file descriptor handling for SAF-based outputs -- **Metadata Matching Robustness**: Improved title matching with strict emoji handling and name+artist fallback lookup behavior -- **Navigation Behavior**: Back button now exits app correctly instead of unexpectedly returning to home - ---- - -## [4.0.0] - 2026-02-22 - -### Added - -- **Interaction Mode Setting**: New "Interaction Mode" toggle in Options settings to switch between Downloader Mode (tap to queue downloads) and Streaming Mode (tap to play instantly) - - Affects album, artist discography, playlist, home explore, and search screens - - All action buttons (Download All, Download Selected, Download Discography) dynamically switch to Play equivalents when in Streaming Mode -- **Streaming Playback Integration**: Tapping tracks in Streaming Mode plays them via `playTrackStreamAndSetQueue` with full queue support across all collection screens (album, artist, playlist, home, search) -- **Long-Press Track Context Menus**: Added `onLongPress` handler on track items across album, artist, home, playlist, and search screens to open the track options bottom sheet via `TrackCollectionQuickActions.showTrackOptionsSheet` -- **USDT TRC20 Crypto Donation**: Added USDT (TRC20) wallet address to Donate page with tap-to-copy-to-clipboard functionality and snackbar confirmation -- **Localization**: Added interaction mode and streaming playback strings across all 14 supported locales (`optionsInteractionMode`, `modeDownloader`, `modeDownloaderSubtitle`, `modeStreaming`, `modeStreamingSubtitle`, `playAllCount`, `discographyPlay`, `discographyPlayAll`, `discographyPlaySelected`) -- **Indonesian (ID) Localization**: Full translations for all new streaming mode strings - -### Changed - -- **Mini Player Bar Layout**: Media section (cover art / lyrics) now uses fixed-height `SizedBox` (50% screen height, clamped 300–560px) instead of `Expanded` for more consistent layout -- **Lyrics Font Size Increase**: Synced lyrics current line 22→24px, non-current 18→19px; word-by-word highlight 22→24px; unsynced 18→19px -- **Playback Media Controls**: Removed stop button from notification media controls for cleaner transport bar -- **Playback Queue Exhaustion**: Player now properly syncs `ProcessingState.completed` state when queue is exhausted instead of silently stopping -- **`TrackCollectionQuickActions.showTrackOptionsSheet` Made Static**: Extracted to a public static method so all screens can invoke it directly for long-press handling -- **Bottom Spacing in Mini Player**: Reduced from 16px to 4px for tighter layout - -### Fixed - -- **Playback State Not Updating on Queue End**: Fixed playback notification staying in "playing" state when all tracks in queue have finished - ---- - -## [3.7.0] - 2026-02-19 +## [3.6.0] - 2026-02-19 ### Added diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index d9752c2c..2bd4f8d5 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -80,16 +80,6 @@ -keep class io.flutter.plugins.pathprovider.** { *; } -keep class dev.flutter.pigeon.** { *; } -# Audio Service (media playback notification) - CRITICAL for release builds --keep class com.ryanheise.audioservice.** { *; } --keep class com.ryanheise.audio_session.** { *; } --keep class com.ryanheise.just_audio.** { *; } - -# AndroidX Media / MediaSession (used by audio_service) --keep class androidx.media.** { *; } --keep class android.support.v4.media.** { *; } --dontwarn android.support.v4.media.** - # Local Notifications -keep class com.dexterous.** { *; } -keep class com.dexterous.flutterlocalnotifications.** { *; } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 1c8769d2..c30a1775 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -7,7 +7,7 @@ import android.os.Build import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile -import com.ryanheise.audioservice.AudioServiceFragmentActivity +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode import io.flutter.embedding.android.FlutterFragment import io.flutter.embedding.android.RenderMode @@ -32,7 +32,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.Locale -class MainActivity: AudioServiceFragmentActivity() { +class MainActivity: FlutterFragmentActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" private val DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream" @@ -50,6 +50,7 @@ class MainActivity: AudioServiceFragmentActivity() { private var libraryScanProgressStreamJob: Job? = null private var libraryScanProgressEventSink: EventChannel.EventSink? = null private var lastLibraryScanProgressPayload: String? = null + private var flutterBackCallback: OnBackPressedCallback? = null @Volatile private var safScanCancel = false @Volatile private var safScanActive = false private val safTreeLauncher = registerForActivityResult( @@ -1370,11 +1371,12 @@ class MainActivity: AudioServiceFragmentActivity() { // which disables Flutter's own OnBackPressedCallback and causes the // system default (finish activity) to run. This callback guarantees // popRoute is always forwarded to Flutter, where PopScope handles it. - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + flutterBackCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { flutterEngine.navigationChannel.popRoute() } - }) + } + onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) val messenger = flutterEngine.dartExecutor.binaryMessenger @@ -1416,6 +1418,15 @@ class MainActivity: AudioServiceFragmentActivity() { scope.launch { try { when (call.method) { + "exitApp" -> { + flutterBackCallback?.isEnabled = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask() + } else { + finish() + } + result.success(null) + } "parseSpotifyUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { diff --git a/android/app/src/main/res/drawable/ic_stat_favorite.xml b/android/app/src/main/res/drawable/ic_stat_favorite.xml deleted file mode 100644 index 6ef85758..00000000 --- a/android/app/src/main/res/drawable/ic_stat_favorite.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_stat_favorite_border.xml b/android/app/src/main/res/drawable/ic_stat_favorite_border.xml deleted file mode 100644 index 7e803abf..00000000 --- a/android/app/src/main/res/drawable/ic_stat_favorite_border.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml deleted file mode 100644 index c71ae08e..00000000 --- a/android/app/src/main/res/raw/keep.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 044e1315..52653172 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -2,6 +2,7 @@ package gobackend import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -30,8 +31,21 @@ var ( const ( qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" + qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" + qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" + qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" + qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzDebugKeyXORMask = byte(0x5A) ) +var qobuzDebugKeyObfuscated = []byte{ + 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, + 0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b, + 0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37, + 0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29, + 0x3f, +} + type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -368,45 +382,181 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { func (q *QobuzDownloader) GetAvailableAPIs() []string { return []string{ - "https://dab.yeet.su/api/stream?trackId=", - "https://dabmusic.xyz/api/stream?trackId=", + qobuzDownloadAPIURL, } } -func extractQobuzDownloadURLFromBody(body []byte) (string, error) { +type qobuzAPIProvider struct { + Name string + URL string + Kind string +} + +const ( + qobuzAPIKindMusicDL = "musicdl" + qobuzAPIKindStandard = "standard" +) + +func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { + return []qobuzAPIProvider{ + {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, + {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, + // "deeb" is mapped from the legacy reference fallback endpoint. + {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + } +} + +type qobuzDownloadInfo struct { + DownloadURL string + BitDepth int + SampleRate int +} + +func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) { var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { - return "", fmt.Errorf("invalid JSON: %v", err) + return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err) } if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return "", fmt.Errorf("%s", errMsg) + return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg) + } + if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" { + return qobuzDownloadInfo{}, fmt.Errorf("%s", detail) } if success, ok := raw["success"].(bool); ok && !success { if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" { - return "", fmt.Errorf("%s", msg) + return qobuzDownloadInfo{}, fmt.Errorf("%s", msg) } - return "", fmt.Errorf("api returned success=false") + return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false") } + info := qobuzDownloadInfo{ + BitDepth: qobuzParseBitDepth(raw["bit_depth"]), + SampleRate: qobuzParseSampleRate(raw["sampling_rate"]), + } + if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil + } if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil } if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - return strings.TrimSpace(linkVal), nil + info.DownloadURL = strings.TrimSpace(linkVal) + return info, nil } if data, ok := raw["data"].(map[string]any); ok { + if info.BitDepth == 0 { + info.BitDepth = qobuzParseBitDepth(data["bit_depth"]) + } + if info.SampleRate == 0 { + info.SampleRate = qobuzParseSampleRate(data["sampling_rate"]) + } + if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil + } if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil } if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - return strings.TrimSpace(linkVal), nil + info.DownloadURL = strings.TrimSpace(linkVal) + return info, nil } } - return "", fmt.Errorf("no download URL in response") + return qobuzDownloadInfo{}, fmt.Errorf("no download URL in response") +} + +func extractQobuzDownloadURLFromBody(body []byte) (string, error) { + info, err := extractQobuzDownloadInfoFromBody(body) + if err != nil { + return "", err + } + return info.DownloadURL, nil +} + +func qobuzParseBitDepth(value any) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + case json.Number: + n, _ := v.Int64() + return int(n) + default: + return 0 + } +} + +func qobuzParseSampleRate(value any) int { + switch v := value.(type) { + case float64: + if v > 0 && v < 1000 { + return int(v * 1000) + } + return int(v) + case int: + if v > 0 && v < 1000 { + return v * 1000 + } + return v + case int64: + if v > 0 && v < 1000 { + return int(v * 1000) + } + return int(v) + case json.Number: + if n, err := v.Float64(); err == nil { + if n > 0 && n < 1000 { + return int(n * 1000) + } + return int(n) + } + return 0 + default: + return 0 + } +} + +func normalizeQobuzQualityCode(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "", "5", "6", "cd", "lossless": + return "6" + case "7", "hi-res": + return "7" + case "27", "hi-res-max": + return "27" + default: + return "6" + } +} + +func mapQobuzQualityCodeToAPI(qualityCode string) string { + switch normalizeQobuzQualityCode(qualityCode) { + case "27": + return "hi-res-max" + case "7": + return "hi-res" + default: + return "cd" + } +} + +func getQobuzDebugKey() string { + decoded := make([]byte, len(qobuzDebugKeyObfuscated)) + for i, b := range qobuzDebugKeyObfuscated { + decoded[i] = b ^ qobuzDebugKeyXORMask + } + return string(decoded) } func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { @@ -688,10 +838,10 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } type qobuzAPIResult struct { - apiURL string - downloadURL string - err error - duration time.Duration + provider qobuzAPIProvider + info qobuzDownloadInfo + err error + duration time.Duration } // Qobuz API timeout configuration @@ -711,35 +861,72 @@ func getQobuzAPITimeout() time.Duration { } // fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic -func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) { - return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "") +func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) { + return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } // fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination -func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) { +func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay + var payloadBytes []byte + if provider.Kind == qobuzAPIKindMusicDL { + requestQuality := mapQobuzQualityCodeToAPI(quality) + payload := map[string]any{ + "quality": requestQuality, + "upload_to_r2": false, + "url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID), + } + var err error + payloadBytes, err = json.Marshal(payload) + if err != nil { + return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) + } + } for attempt := 0; attempt <= qobuzMaxRetries; attempt++ { if attempt > 0 { - GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay) + GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay) time.Sleep(retryDelay) retryDelay *= 2 // Exponential backoff } client := NewHTTPClientWithTimeout(timeout) - reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) + reqURL := provider.URL if country != "" { - reqURL += "&country=" + country + reqURL += "?country=" + url.QueryEscape(country) } - req, err := http.NewRequest("GET", reqURL, nil) + var ( + req *http.Request + err error + ) + if provider.Kind == qobuzAPIKindStandard { + separator := "&" + if !strings.Contains(reqURL, "?") { + separator = "?" + } + reqURL = fmt.Sprintf( + "%s%d%squality=%s", + reqURL, + trackID, + separator, + url.QueryEscape(normalizeQobuzQualityCode(quality)), + ) + req, err = http.NewRequest("GET", reqURL, nil) + } else { + req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes)) + } if err != nil { lastErr = err continue } + if provider.Kind == qobuzAPIKindMusicDL { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Debug-Key", getQobuzDebugKey()) + } - resp, err := client.Do(req) + resp, err := DoRequestWithUserAgent(client, req) if err != nil { lastErr = err // Check for retryable errors (timeout, connection reset) @@ -772,7 +959,7 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo if resp.StatusCode != 200 { io.Copy(io.Discard, resp.Body) resp.Body.Close() - return "", fmt.Errorf("HTTP %d", resp.StatusCode) + return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) @@ -783,117 +970,115 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo } if len(body) > 0 && body[0] == '<' { - return "", fmt.Errorf("received HTML instead of JSON") + return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON") } - urlVal, parseErr := extractQobuzDownloadURLFromBody(body) + info, parseErr := extractQobuzDownloadInfoFromBody(body) if parseErr == nil { - return urlVal, nil + return info, nil } lastErr = parseErr continue } if lastErr != nil { - return "", lastErr + return qobuzDownloadInfo{}, lastErr } - return "", fmt.Errorf("all retries failed") + return qobuzDownloadInfo{}, fmt.Errorf("all retries failed") } -func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { - if len(apis) == 0 { - return "", "", fmt.Errorf("no APIs available") +func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) { + if len(providers) == 0 { + return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available") } - GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis)) + GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers)) - resultChan := make(chan qobuzAPIResult, len(apis)) + resultChan := make(chan qobuzAPIResult, len(providers)) startTime := time.Now() timeout := getQobuzAPITimeout() - for _, apiURL := range apis { - go func(api string) { + for _, provider := range providers { + go func(provider qobuzAPIProvider) { reqStart := time.Now() - downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout) + info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout) resultChan <- qobuzAPIResult{ - apiURL: api, - downloadURL: downloadURL, - err: err, - duration: time.Since(reqStart), + provider: provider, + info: info, + err: err, + duration: time.Since(reqStart), } - }(apiURL) + }(provider) } var errors []string - for i := 0; i < len(apis); i++ { + for i := 0; i < len(providers); i++ { result := <-resultChan if result.err == nil { - GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) + GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.provider.Name, result.duration) go func(remaining int) { for j := 0; j < remaining; j++ { <-resultChan } - }(len(apis) - i - 1) + }(len(providers) - i - 1) GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) - return result.apiURL, result.downloadURL, nil + return result.provider, result.info, nil } errMsg := result.err.Error() if len(errMsg) > 50 { errMsg = errMsg[:50] + "..." } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) + errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg)) } - GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime)) - return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) + GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime)) + return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors) } -func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { - apis := q.GetAvailableAPIs() - if len(apis) == 0 { - return "", fmt.Errorf("no Qobuz API available") +func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) { + providers := q.GetAvailableProviders() + if len(providers) == 0 { + return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available") } - qualityCode := strings.TrimSpace(quality) - if qualityCode == "" || qualityCode == "5" { - qualityCode = "6" - } + qualityCode := normalizeQobuzQualityCode(quality) - downloadFunc := func(qual string) (string, error) { - _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, qual) + downloadFunc := func(qual string) (qobuzDownloadInfo, error) { + provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual) if err != nil { - return "", err + return qobuzDownloadInfo{}, err } - return downloadURL, nil + GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name) + return info, nil } - downloadURL, err := downloadFunc(qualityCode) + downloadInfo, err := downloadFunc(qualityCode) if err == nil { - return downloadURL, nil + return downloadInfo, nil } currentQuality := qualityCode if currentQuality == "27" { GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") - downloadURL, err = downloadFunc("7") + downloadInfo, err = downloadFunc("7") if err == nil { - return downloadURL, nil + return downloadInfo, nil } currentQuality = "7" } if currentQuality == "7" { GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n") - downloadURL, err = downloadFunc("6") + downloadInfo, err = downloadFunc("6") if err == nil { - return downloadURL, nil + return downloadInfo, nil } } - return "", fmt.Errorf("all Qobuz APIs failed: %w", err) + return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err) } func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { @@ -1150,10 +1335,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { actualSampleRate := int(track.MaximumSamplingRate * 1000) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) - downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) + downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality) if err != nil { return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } + if downloadInfo.BitDepth > 0 { + actualBitDepth = downloadInfo.BitDepth + } + if downloadInfo.SampleRate > 0 { + actualSampleRate = downloadInfo.SampleRate + } + if actualBitDepth > 0 || actualSampleRate > 0 { + GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate) + } var parallelResult *ParallelDownloadResult parallelDone := make(chan struct{}) @@ -1176,7 +1370,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { ) }() - if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { + if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return QobuzDownloadResult{}, ErrDownloadCancelled } diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 124cd61e..0382b62f 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -3,6 +3,24 @@ package gobackend import "testing" func TestExtractQobuzDownloadURLFromBody(t *testing.T) { + t.Run("reads top-level download_url and quality metadata", func(t *testing.T) { + body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`) + + info, err := extractQobuzDownloadInfoFromBody(body) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if info.DownloadURL != "https://example.test/new.flac" { + t.Fatalf("unexpected URL: %q", info.DownloadURL) + } + if info.BitDepth != 24 { + t.Fatalf("unexpected bit depth: %d", info.BitDepth) + } + if info.SampleRate != 96000 { + t.Fatalf("unexpected sample rate: %d", info.SampleRate) + } + }) + t.Run("reads nested data.url", func(t *testing.T) { body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`) @@ -44,4 +62,74 @@ func TestExtractQobuzDownloadURLFromBody(t *testing.T) { t.Fatalf("expected blocked error, got %v", err) } }) + + t.Run("returns detail error", func(t *testing.T) { + body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`) + + _, err := extractQobuzDownloadURLFromBody(body) + if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" { + t.Fatalf("expected detail error, got %v", err) + } + }) +} + +func TestNormalizeQobuzQualityCode(t *testing.T) { + tests := map[string]string{ + "": "6", + "5": "6", + "6": "6", + "cd": "6", + "lossless": "6", + "7": "7", + "hi-res": "7", + "27": "27", + "hi-res-max": "27", + "unexpected": "6", + } + + for input, want := range tests { + if got := normalizeQobuzQualityCode(input); got != want { + t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want) + } + } +} + +func TestGetQobuzDebugKey(t *testing.T) { + got := getQobuzDebugKey() + if len(got) != len(qobuzDebugKeyObfuscated) { + t.Fatalf("unexpected debug key length: %d", len(got)) + } + for i := range got { + if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] { + t.Fatalf("unexpected debug key reconstruction at index %d", i) + } + } +} + +func TestQobuzAvailableProviders(t *testing.T) { + providers := NewQobuzDownloader().GetAvailableProviders() + if len(providers) != 3 { + t.Fatalf("expected 3 Qobuz providers, got %d", len(providers)) + } + + want := map[string]string{ + "musicdl": qobuzAPIKindMusicDL, + "dabmusic": qobuzAPIKindStandard, + "deeb": qobuzAPIKindStandard, + } + + for _, provider := range providers { + wantKind, ok := want[provider.Name] + if !ok { + t.Fatalf("unexpected provider %q", provider.Name) + } + if provider.Kind != wantKind { + t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind) + } + delete(want, provider.Name) + } + + if len(want) != 0 { + t.Fatalf("missing providers: %v", want) + } } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 121bd07e..e786ec11 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '4.0.1'; - static const String buildNumber = '102'; + static const String version = '3.7.0'; + static const String buildNumber = '103'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bccbf1fc..85962c2a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5699,318 +5699,6 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You can switch between modes anytime in Settings.'** String get setupModeChangeableLater; - - /// Title for Smart Queue toggle in settings - /// - /// In en, this message translates to: - /// **'Smart Queue'** - String get settingsSmartQueueTitle; - - /// Subtitle for Smart Queue toggle in settings - /// - /// In en, this message translates to: - /// **'Automatically discover and add similar tracks to your queue'** - String get settingsSmartQueueSubtitle; - - /// Title for the What's New screen - /// - /// In en, this message translates to: - /// **'What\'s New in 4.0'** - String get whatsNewTitle; - - /// Subtitle for the What's New screen - /// - /// In en, this message translates to: - /// **'SpotiFLAC has evolved — here\'s what changed since 3.x'** - String get whatsNewSubtitle; - - /// Welcome page title in What's New screen - /// - /// In en, this message translates to: - /// **'SpotiFLAC Mobile 4.0'** - String get whatsNewWelcomeTitle; - - /// Welcome page description in What's New screen - /// - /// In en, this message translates to: - /// **'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'** - String get whatsNewWelcomeDesc; - - /// Welcome page tip 1 - /// - /// In en, this message translates to: - /// **'New streaming mode with instant playback'** - String get whatsNewWelcomeTip1; - - /// Welcome page tip 2 - /// - /// In en, this message translates to: - /// **'Redesigned library and full-screen player'** - String get whatsNewWelcomeTip2; - - /// Welcome page tip 3 - /// - /// In en, this message translates to: - /// **'Batch tools, performance boosts, and more'** - String get whatsNewWelcomeTip3; - - /// What's New feature: Streaming Mode title - /// - /// In en, this message translates to: - /// **'Streaming Mode'** - String get whatsNewStreamingTitle; - - /// What's New feature: Streaming Mode description - /// - /// In en, this message translates to: - /// **'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'** - String get whatsNewStreamingDesc; - - /// What's New feature: Smart Queue title - /// - /// In en, this message translates to: - /// **'Smart Queue'** - String get whatsNewSmartQueueTitle; - - /// What's New feature: Smart Queue description - /// - /// In en, this message translates to: - /// **'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'** - String get whatsNewSmartQueueDesc; - - /// What's New feature: Dual Mode title - /// - /// In en, this message translates to: - /// **'Dual Mode'** - String get whatsNewDualModeTitle; - - /// What's New feature: Dual Mode description - /// - /// In en, this message translates to: - /// **'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'** - String get whatsNewDualModeDesc; - - /// What's New feature: Library redesign title - /// - /// In en, this message translates to: - /// **'Redesigned Library'** - String get whatsNewLibraryTitle; - - /// What's New feature: Library redesign description - /// - /// In en, this message translates to: - /// **'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'** - String get whatsNewLibraryDesc; - - /// What's New feature: Full-Screen Player title - /// - /// In en, this message translates to: - /// **'Full-Screen Player'** - String get whatsNewPlayerTitle; - - /// What's New feature: Full-Screen Player description - /// - /// In en, this message translates to: - /// **'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'** - String get whatsNewPlayerDesc; - - /// What's New feature: Context Menus title - /// - /// In en, this message translates to: - /// **'Long-Press Menus'** - String get whatsNewContextMenuTitle; - - /// What's New feature: Context Menus description - /// - /// In en, this message translates to: - /// **'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'** - String get whatsNewContextMenuDesc; - - /// What's New feature: Performance title - /// - /// In en, this message translates to: - /// **'Performance'** - String get whatsNewPerformanceTitle; - - /// What's New feature: Performance description - /// - /// In en, this message translates to: - /// **'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'** - String get whatsNewPerformanceDesc; - - /// What's New feature: Batch Tools title - /// - /// In en, this message translates to: - /// **'Batch Tools'** - String get whatsNewBatchToolsTitle; - - /// What's New feature: Batch Tools description - /// - /// In en, this message translates to: - /// **'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'** - String get whatsNewBatchToolsDesc; - - /// What's New tip: streaming instant play - /// - /// In en, this message translates to: - /// **'Tap any track to start playing instantly'** - String get whatsNewStreamingTip1; - - /// What's New tip: streaming synced lyrics - /// - /// In en, this message translates to: - /// **'Synced lyrics in the full-screen player'** - String get whatsNewStreamingTip2; - - /// What's New tip: streaming download from player - /// - /// In en, this message translates to: - /// **'Download tracks directly from the player'** - String get whatsNewStreamingTip3; - - /// What's New tip: smart queue auto-fill - /// - /// In en, this message translates to: - /// **'Queue auto-fills with related tracks'** - String get whatsNewSmartQueueTip1; - - /// What's New tip: smart queue artist discovery - /// - /// In en, this message translates to: - /// **'Discover new artists as you listen'** - String get whatsNewSmartQueueTip2; - - /// What's New tip: smart queue endless - /// - /// In en, this message translates to: - /// **'Never run out of music to play'** - String get whatsNewSmartQueueTip3; - - /// What's New tip: dual mode switch - /// - /// In en, this message translates to: - /// **'Switch modes anytime in Settings'** - String get whatsNewDualModeTip1; - - /// What's New tip: dual mode adaptive UI - /// - /// In en, this message translates to: - /// **'UI buttons adapt to your current mode'** - String get whatsNewDualModeTip2; - - /// What's New tip: dual mode use cases - /// - /// In en, this message translates to: - /// **'Download for offline, stream for instant play'** - String get whatsNewDualModeTip3; - - /// What's New tip: library drag and drop - /// - /// In en, this message translates to: - /// **'Drag and drop to organize playlists'** - String get whatsNewLibraryTip1; - - /// What's New tip: library custom covers - /// - /// In en, this message translates to: - /// **'Set custom cover images for playlists'** - String get whatsNewLibraryTip2; - - /// What's New tip: library multi-select - /// - /// In en, this message translates to: - /// **'Multi-select tracks for batch actions'** - String get whatsNewLibraryTip3; - - /// What's New tip: player parallax - /// - /// In en, this message translates to: - /// **'Cover art with parallax scrolling effect'** - String get whatsNewPlayerTip1; - - /// What's New tip: player persistence - /// - /// In en, this message translates to: - /// **'Playback persists across app restarts'** - String get whatsNewPlayerTip2; - - /// What's New tip: player lyrics - /// - /// In en, this message translates to: - /// **'Synced lyrics while you listen'** - String get whatsNewPlayerTip3; - - /// What's New tip: context menu add to playlist - /// - /// In en, this message translates to: - /// **'Add tracks to any playlist instantly'** - String get whatsNewContextMenuTip1; - - /// What's New tip: context menu share/convert - /// - /// In en, this message translates to: - /// **'Share or convert with one tap'** - String get whatsNewContextMenuTip2; - - /// What's New tip: context menu re-enrich - /// - /// In en, this message translates to: - /// **'Re-enrich metadata when needed'** - String get whatsNewContextMenuTip3; - - /// What's New tip: batch share - /// - /// In en, this message translates to: - /// **'Share multiple tracks at once'** - String get whatsNewBatchToolsTip1; - - /// What's New tip: batch convert - /// - /// In en, this message translates to: - /// **'Batch convert to MP3 or Opus format'** - String get whatsNewBatchToolsTip2; - - /// What's New tip: batch re-enrich - /// - /// In en, this message translates to: - /// **'Re-enrich metadata across your library'** - String get whatsNewBatchToolsTip3; - - /// What's New tip: performance startup - /// - /// In en, this message translates to: - /// **'Faster app startup time'** - String get whatsNewPerformanceTip1; - - /// What's New tip: performance memory - /// - /// In en, this message translates to: - /// **'Reduced memory usage during playback'** - String get whatsNewPerformanceTip2; - - /// What's New tip: performance SQLite - /// - /// In en, this message translates to: - /// **'SQLite-backed storage for reliability'** - String get whatsNewPerformanceTip3; - - /// Ready card message on last What's New page - /// - /// In en, this message translates to: - /// **'You\'re all set — enjoy the new SpotiFLAC!'** - String get whatsNewReadyMessage; - - /// Button text to dismiss What's New screen - /// - /// In en, this message translates to: - /// **'Let\'s Go'** - String get whatsNewGetStarted; - - /// Page indicator text in What's New screen - /// - /// In en, this message translates to: - /// **'{current} of {total}'** - String whatsNewPageIndicator(int current, int total); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 30bea9cf..f5287500 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3288,177 +3288,4 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupModeChangeableLater => 'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e9544e94..e5da5324 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3266,177 +3266,4 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupModeChangeableLater => 'You can switch between modes anytime in Settings.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Automatically discover and add similar tracks to your queue'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5f805c85..303eb62b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3267,179 +3267,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupModeChangeableLater => 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubre y añade automáticamente pistas similares a tu cola de reproducción'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). @@ -6447,11 +6274,4 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupModeChangeableLater => 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubre y añade automáticamente pistas similares a tu cola de reproducción'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 2909fc46..8c5febcf 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3273,177 +3273,4 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupModeChangeableLater => 'Vous pouvez changer de mode à tout moment dans les Paramètres.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Découvrir et ajouter automatiquement des pistes similaires à votre file d\'attente'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 8b90147c..3c3388d9 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3267,177 +3267,4 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupModeChangeableLater => 'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 7a9fad81..c687f715 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3282,186 +3282,4 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupModeChangeableLater => 'Anda dapat beralih antar mode kapan saja di Pengaturan.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda'; - - @override - String get whatsNewTitle => 'Yang Baru di 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.'; - - @override - String get whatsNewWelcomeTip1 => - 'Mode streaming baru dengan pemutaran instan'; - - @override - String get whatsNewWelcomeTip2 => - 'Perpustakaan dan pemutar layar penuh yang didesain ulang'; - - @override - String get whatsNewWelcomeTip3 => - 'Alat massal, peningkatan performa, dan lainnya'; - - @override - String get whatsNewStreamingTitle => 'Mode Streaming'; - - @override - String get whatsNewStreamingDesc => - 'Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.'; - - @override - String get whatsNewDualModeTitle => 'Mode Ganda'; - - @override - String get whatsNewDualModeDesc => - 'Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.'; - - @override - String get whatsNewLibraryTitle => 'Perpustakaan Baru'; - - @override - String get whatsNewLibraryDesc => - 'Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.'; - - @override - String get whatsNewPlayerTitle => 'Pemutar Layar Penuh'; - - @override - String get whatsNewPlayerDesc => - 'Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.'; - - @override - String get whatsNewContextMenuTitle => 'Menu Tekan Lama'; - - @override - String get whatsNewContextMenuDesc => - 'Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performa'; - - @override - String get whatsNewPerformanceDesc => - 'Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.'; - - @override - String get whatsNewBatchToolsTitle => 'Alat Massal'; - - @override - String get whatsNewBatchToolsDesc => - 'Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.'; - - @override - String get whatsNewStreamingTip1 => - 'Ketuk trek apa pun untuk langsung memutar'; - - @override - String get whatsNewStreamingTip2 => 'Lirik tersinkron di pemutar layar penuh'; - - @override - String get whatsNewStreamingTip3 => 'Unduh trek langsung dari pemutar'; - - @override - String get whatsNewSmartQueueTip1 => - 'Antrean terisi otomatis dengan trek terkait'; - - @override - String get whatsNewSmartQueueTip2 => 'Temukan artis baru saat mendengarkan'; - - @override - String get whatsNewSmartQueueTip3 => - 'Tak pernah kehabisan musik untuk diputar'; - - @override - String get whatsNewDualModeTip1 => 'Beralih mode kapan saja di Pengaturan'; - - @override - String get whatsNewDualModeTip2 => 'Tombol UI menyesuaikan dengan mode Anda'; - - @override - String get whatsNewDualModeTip3 => - 'Unduh untuk offline, streaming untuk putar langsung'; - - @override - String get whatsNewLibraryTip1 => 'Seret dan lepas untuk mengatur playlist'; - - @override - String get whatsNewLibraryTip2 => 'Atur gambar sampul kustom untuk playlist'; - - @override - String get whatsNewLibraryTip3 => 'Pilih banyak trek untuk aksi massal'; - - @override - String get whatsNewPlayerTip1 => 'Seni sampul dengan efek paralaks'; - - @override - String get whatsNewPlayerTip2 => 'Pemutaran tetap tersimpan saat restart'; - - @override - String get whatsNewPlayerTip3 => 'Lirik tersinkron saat mendengarkan'; - - @override - String get whatsNewContextMenuTip1 => - 'Tambahkan trek ke playlist mana pun langsung'; - - @override - String get whatsNewContextMenuTip2 => - 'Bagikan atau konversi dengan satu ketukan'; - - @override - String get whatsNewContextMenuTip3 => 'Perbarui metadata saat diperlukan'; - - @override - String get whatsNewBatchToolsTip1 => 'Bagikan banyak trek sekaligus'; - - @override - String get whatsNewBatchToolsTip2 => - 'Konversi massal ke format MP3 atau Opus'; - - @override - String get whatsNewBatchToolsTip3 => - 'Perbarui metadata di seluruh perpustakaan'; - - @override - String get whatsNewPerformanceTip1 => 'Waktu startup aplikasi lebih cepat'; - - @override - String get whatsNewPerformanceTip2 => - 'Penggunaan memori berkurang saat pemutaran'; - - @override - String get whatsNewPerformanceTip3 => - 'Penyimpanan berbasis SQLite untuk keandalan'; - - @override - String get whatsNewReadyMessage => 'Siap — nikmati SpotiFLAC yang baru!'; - - @override - String get whatsNewGetStarted => 'Ayo Mulai'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current dari $total'; - } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 7628d16b..78b99777 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3246,176 +3246,4 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '類似トラックを自動的に検出してキューに追加'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index c9aa8d92..ce04dc34 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3259,176 +3259,4 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '유사한 트랙을 자동으로 검색하여 대기열에 추가'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ed4810d0..052c9204 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3267,177 +3267,4 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupModeChangeableLater => 'Je kunt op elk moment wisselen tussen modi in Instellingen.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 9a341fad..0262c76f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3267,179 +3267,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupModeChangeableLater => 'Você pode alternar entre os modos a qualquer momento nas Configurações.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubra e adicione automaticamente faixas semelhantes à sua fila'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -6441,11 +6268,4 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupModeChangeableLater => 'Pode alternar entre modos a qualquer momento nas Definições.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubra e adicione automaticamente faixas semelhantes à sua fila'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3df13ff8..e6b4a1f8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3365,177 +3365,4 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupModeChangeableLater => 'Вы можете переключаться между режимами в любое время в Настройках.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Автоматически находите и добавляйте похожие треки в очередь воспроизведения'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 935c31d4..164833ef 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3281,177 +3281,4 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupModeChangeableLater => 'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c4a6f4aa..f0b5c82b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3259,178 +3259,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } /// The translations for Chinese, as used in China (`zh_CN`). @@ -6397,12 +6225,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -9369,10 +9191,4 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupModeChangeableLater => '您可以隨時在設定中切換模式。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '自動探索並將相似曲目新增到您的佇列中'; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cf06c2ed..869d5559 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen", "setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich", "setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung", - "setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen" + "setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln." } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 562073ff..709c1213 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2495,117 +2495,5 @@ "setupModeStreamingFeature3": "Play any track on demand with playback controls", "@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"}, "setupModeChangeableLater": "You can switch between modes anytime in Settings.", - "@setupModeChangeableLater": {"description": "Hint that mode can be changed later"}, - - "settingsSmartQueueTitle": "Smart Queue", - "@settingsSmartQueueTitle": {"description": "Title for Smart Queue toggle in settings"}, - "settingsSmartQueueSubtitle": "Automatically discover and add similar tracks to your queue", - "@settingsSmartQueueSubtitle": {"description": "Subtitle for Smart Queue toggle in settings"}, - - "whatsNewTitle": "What's New in 4.0", - "@whatsNewTitle": {"description": "Title for the What's New screen"}, - "whatsNewSubtitle": "SpotiFLAC has evolved — here's what changed since 3.x", - "@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"}, - "whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0", - "@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"}, - "whatsNewWelcomeDesc": "Welcome back! This is a major update packed with new features. Swipe through to see what's changed.", - "@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"}, - "whatsNewWelcomeTip1": "New streaming mode with instant playback", - "@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"}, - "whatsNewWelcomeTip2": "Redesigned library and full-screen player", - "@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"}, - "whatsNewWelcomeTip3": "Batch tools, performance boosts, and more", - "@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"}, - "whatsNewStreamingTitle": "Streaming Mode", - "@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"}, - "whatsNewStreamingDesc": "Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.", - "@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"}, - "whatsNewSmartQueueTitle": "Smart Queue", - "@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"}, - "whatsNewSmartQueueDesc": "Your queue auto-curates with related tracks and artist discovery. Never run out of music.", - "@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"}, - "whatsNewDualModeTitle": "Dual Mode", - "@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"}, - "whatsNewDualModeDesc": "Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.", - "@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"}, - "whatsNewLibraryTitle": "Redesigned Library", - "@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"}, - "whatsNewLibraryDesc": "Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.", - "@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"}, - "whatsNewPlayerTitle": "Full-Screen Player", - "@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"}, - "whatsNewPlayerDesc": "Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.", - "@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"}, - "whatsNewContextMenuTitle": "Long-Press Menus", - "@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"}, - "whatsNewContextMenuDesc": "Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.", - "@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"}, - "whatsNewPerformanceTitle": "Performance", - "@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"}, - "whatsNewPerformanceDesc": "Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.", - "@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"}, - "whatsNewBatchToolsTitle": "Batch Tools", - "@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"}, - "whatsNewBatchToolsDesc": "Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.", - "@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"}, - "whatsNewStreamingTip1": "Tap any track to start playing instantly", - "@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"}, - "whatsNewStreamingTip2": "Synced lyrics in the full-screen player", - "@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"}, - "whatsNewStreamingTip3": "Download tracks directly from the player", - "@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"}, - "whatsNewSmartQueueTip1": "Queue auto-fills with related tracks", - "@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"}, - "whatsNewSmartQueueTip2": "Discover new artists as you listen", - "@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"}, - "whatsNewSmartQueueTip3": "Never run out of music to play", - "@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"}, - "whatsNewDualModeTip1": "Switch modes anytime in Settings", - "@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"}, - "whatsNewDualModeTip2": "UI buttons adapt to your current mode", - "@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"}, - "whatsNewDualModeTip3": "Download for offline, stream for instant play", - "@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"}, - "whatsNewLibraryTip1": "Drag and drop to organize playlists", - "@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"}, - "whatsNewLibraryTip2": "Set custom cover images for playlists", - "@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"}, - "whatsNewLibraryTip3": "Multi-select tracks for batch actions", - "@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"}, - "whatsNewPlayerTip1": "Cover art with parallax scrolling effect", - "@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"}, - "whatsNewPlayerTip2": "Playback persists across app restarts", - "@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"}, - "whatsNewPlayerTip3": "Synced lyrics while you listen", - "@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"}, - "whatsNewContextMenuTip1": "Add tracks to any playlist instantly", - "@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"}, - "whatsNewContextMenuTip2": "Share or convert with one tap", - "@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"}, - "whatsNewContextMenuTip3": "Re-enrich metadata when needed", - "@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"}, - "whatsNewBatchToolsTip1": "Share multiple tracks at once", - "@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"}, - "whatsNewBatchToolsTip2": "Batch convert to MP3 or Opus format", - "@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"}, - "whatsNewBatchToolsTip3": "Re-enrich metadata across your library", - "@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"}, - "whatsNewPerformanceTip1": "Faster app startup time", - "@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"}, - "whatsNewPerformanceTip2": "Reduced memory usage during playback", - "@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"}, - "whatsNewPerformanceTip3": "SQLite-backed storage for reliability", - "@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"}, - "whatsNewReadyMessage": "You're all set — enjoy the new SpotiFLAC!", - "@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"}, - "whatsNewGetStarted": "Let's Go", - "@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"}, - "whatsNewPageIndicator": "{current} of {total}", - "@whatsNewPageIndicator": { - "description": "Page indicator text in What's New screen", - "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} - } - } + "@setupModeChangeableLater": {"description": "Hint that mode can be changed later"} } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 8d9a8fb9..f53b9487 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2576,7 +2576,5 @@ "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", - "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción" + "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes." } \ No newline at end of file diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index ccb341db..6065fd9d 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", - "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción" + "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes." } \ No newline at end of file diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 351c4531..d52346ef 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Diffusez des pistes instantanément sans télécharger", "setupModeStreamingFeature2": "Smart Queue découvre automatiquement de nouvelle musique pour vous", "setupModeStreamingFeature3": "Écoutez n'importe quelle piste à la demande avec les contrôles de lecture", - "setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Découvrir et ajouter automatiquement des pistes similaires à votre file d'attente" + "setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres." } \ No newline at end of file diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 7517340d..e57da31b 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें", "setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है", "setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं", - "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें" + "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।" } \ No newline at end of file diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index c2d4b7d8..cf4de43b 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4201,9 +4201,6 @@ "setupModeStreamingFeature2": "Smart Queue secara otomatis menemukan musik baru untuk Anda", "setupModeStreamingFeature3": "Putar trek apa pun sesuai permintaan dengan kontrol pemutaran", "setupModeChangeableLater": "Anda dapat beralih antar mode kapan saja di Pengaturan.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda", - "selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", @@ -4249,112 +4246,5 @@ "total": {"type": "int"}, "format": {"type": "String"} } - }, - - "whatsNewTitle": "Yang Baru di 4.0", - "@whatsNewTitle": {"description": "Title for the What's New screen"}, - "whatsNewSubtitle": "SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x", - "@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"}, - "whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0", - "@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"}, - "whatsNewWelcomeDesc": "Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.", - "@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"}, - "whatsNewWelcomeTip1": "Mode streaming baru dengan pemutaran instan", - "@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"}, - "whatsNewWelcomeTip2": "Perpustakaan dan pemutar layar penuh yang didesain ulang", - "@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"}, - "whatsNewWelcomeTip3": "Alat massal, peningkatan performa, dan lainnya", - "@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"}, - "whatsNewStreamingTitle": "Mode Streaming", - "@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"}, - "whatsNewStreamingDesc": "Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.", - "@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"}, - "whatsNewSmartQueueTitle": "Smart Queue", - "@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"}, - "whatsNewSmartQueueDesc": "Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.", - "@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"}, - "whatsNewDualModeTitle": "Mode Ganda", - "@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"}, - "whatsNewDualModeDesc": "Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.", - "@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"}, - "whatsNewLibraryTitle": "Perpustakaan Baru", - "@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"}, - "whatsNewLibraryDesc": "Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.", - "@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"}, - "whatsNewPlayerTitle": "Pemutar Layar Penuh", - "@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"}, - "whatsNewPlayerDesc": "Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.", - "@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"}, - "whatsNewContextMenuTitle": "Menu Tekan Lama", - "@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"}, - "whatsNewContextMenuDesc": "Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.", - "@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"}, - "whatsNewPerformanceTitle": "Performa", - "@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"}, - "whatsNewPerformanceDesc": "Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.", - "@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"}, - "whatsNewBatchToolsTitle": "Alat Massal", - "@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"}, - "whatsNewBatchToolsDesc": "Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.", - "@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"}, - "whatsNewStreamingTip1": "Ketuk trek apa pun untuk langsung memutar", - "@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"}, - "whatsNewStreamingTip2": "Lirik tersinkron di pemutar layar penuh", - "@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"}, - "whatsNewStreamingTip3": "Unduh trek langsung dari pemutar", - "@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"}, - "whatsNewSmartQueueTip1": "Antrean terisi otomatis dengan trek terkait", - "@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"}, - "whatsNewSmartQueueTip2": "Temukan artis baru saat mendengarkan", - "@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"}, - "whatsNewSmartQueueTip3": "Tak pernah kehabisan musik untuk diputar", - "@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"}, - "whatsNewDualModeTip1": "Beralih mode kapan saja di Pengaturan", - "@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"}, - "whatsNewDualModeTip2": "Tombol UI menyesuaikan dengan mode Anda", - "@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"}, - "whatsNewDualModeTip3": "Unduh untuk offline, streaming untuk putar langsung", - "@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"}, - "whatsNewLibraryTip1": "Seret dan lepas untuk mengatur playlist", - "@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"}, - "whatsNewLibraryTip2": "Atur gambar sampul kustom untuk playlist", - "@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"}, - "whatsNewLibraryTip3": "Pilih banyak trek untuk aksi massal", - "@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"}, - "whatsNewPlayerTip1": "Seni sampul dengan efek paralaks", - "@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"}, - "whatsNewPlayerTip2": "Pemutaran tetap tersimpan saat restart", - "@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"}, - "whatsNewPlayerTip3": "Lirik tersinkron saat mendengarkan", - "@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"}, - "whatsNewContextMenuTip1": "Tambahkan trek ke playlist mana pun langsung", - "@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"}, - "whatsNewContextMenuTip2": "Bagikan atau konversi dengan satu ketukan", - "@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"}, - "whatsNewContextMenuTip3": "Perbarui metadata saat diperlukan", - "@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"}, - "whatsNewBatchToolsTip1": "Bagikan banyak trek sekaligus", - "@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"}, - "whatsNewBatchToolsTip2": "Konversi massal ke format MP3 atau Opus", - "@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"}, - "whatsNewBatchToolsTip3": "Perbarui metadata di seluruh perpustakaan", - "@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"}, - "whatsNewPerformanceTip1": "Waktu startup aplikasi lebih cepat", - "@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"}, - "whatsNewPerformanceTip2": "Penggunaan memori berkurang saat pemutaran", - "@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"}, - "whatsNewPerformanceTip3": "Penyimpanan berbasis SQLite untuk keandalan", - "@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"}, - "whatsNewReadyMessage": "Siap — nikmati SpotiFLAC yang baru!", - "@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"}, - "whatsNewGetStarted": "Ayo Mulai", - "@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"}, - "whatsNewPageIndicator": "{current} dari {total}", - "@whatsNewPageIndicator": { - "description": "Page indicator text in What's New screen", - "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} - } } } diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 12f1259b..8d66d905 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング", "setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます", "setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生", - "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "類似トラックを自動的に検出してキューに追加" + "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。" } \ No newline at end of file diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index a350b7a2..6c3fa14d 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍", "setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다", "setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생", - "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "유사한 트랙을 자동으로 검색하여 대기열에 추가" + "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다." } \ No newline at end of file diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 34ceb20a..ebac52c4 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Stream nummers direct zonder te downloaden", "setupModeStreamingFeature2": "Smart Queue ontdekt automatisch nieuwe muziek voor je", "setupModeStreamingFeature3": "Speel elk nummer op aanvraag af met afspeelbediening", - "setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij" + "setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen." } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 391b81c3..9c7f026d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2576,7 +2576,5 @@ "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem baixar", "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para você", "setupModeStreamingFeature3": "Reproduza qualquer faixa sob demanda com controles de reprodução", - "setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila" + "setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações." } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index c3e621f9..57592cd5 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem transferir", "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para si", "setupModeStreamingFeature3": "Reproduza qualquer faixa a pedido com controlos de reprodução", - "setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila" + "setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições." } \ No newline at end of file diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 818424cf..f8f9d028 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания", "setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас", "setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления", - "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Автоматически находите и добавляйте похожие треки в очередь воспроизведения" + "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках." } \ No newline at end of file diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 0b736169..ef9184f9 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "İndirmeden parçaları anında yayınlayın", "setupModeStreamingFeature2": "Smart Queue sizin için otomatik olarak yeni müzik keşfeder", "setupModeStreamingFeature3": "İstediğiniz parçayı oynatma kontrolleriyle çalın", - "setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin" + "setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz." } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index bc4c4b4e..0209b3a6 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2576,7 +2576,5 @@ "setupModeStreamingFeature1": "无需下载即可即时播放曲目", "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", - "setupModeChangeableLater": "您可以随时在设置中切换模式。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" + "setupModeChangeableLater": "您可以随时在设置中切换模式。" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 6f34877c..b26eb35e 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "无需下载即可即时播放曲目", "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", - "setupModeChangeableLater": "您可以随时在设置中切换模式。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" + "setupModeChangeableLater": "您可以随时在设置中切换模式。" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 91fb2903..51935549 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "無需下載即可即時串流曲目", "setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂", "setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目", - "setupModeChangeableLater": "您可以隨時在設定中切換模式。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "自動探索並將相似曲目新增到您的佇列中" + "setupModeChangeableLater": "您可以隨時在設定中切換模式。" } \ No newline at end of file diff --git a/lib/models/playback_item.dart b/lib/models/playback_item.dart deleted file mode 100644 index 877674d0..00000000 --- a/lib/models/playback_item.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:spotiflac_android/models/track.dart'; - -class PlaybackItem { - final String id; - final String title; - final String artist; - final String album; - final String coverUrl; - final String sourceUri; - final bool isLocal; - final String service; - final int durationMs; - - // Stream quality metadata - final String format; - final int bitDepth; - final int sampleRate; - final int bitrate; - - // Original track reference for queue operations - final Track? track; - - const PlaybackItem({ - required this.id, - required this.title, - required this.artist, - this.album = '', - this.coverUrl = '', - required this.sourceUri, - this.isLocal = false, - this.service = '', - this.durationMs = 0, - this.format = '', - this.bitDepth = 0, - this.sampleRate = 0, - this.bitrate = 0, - this.track, - }); - - PlaybackItem copyWith({ - String? sourceUri, - String? service, - String? format, - int? bitDepth, - int? sampleRate, - int? bitrate, - }) { - return PlaybackItem( - id: id, - title: title, - artist: artist, - album: album, - coverUrl: coverUrl, - sourceUri: sourceUri ?? this.sourceUri, - isLocal: isLocal, - service: service ?? this.service, - durationMs: durationMs, - format: format ?? this.format, - bitDepth: bitDepth ?? this.bitDepth, - sampleRate: sampleRate ?? this.sampleRate, - bitrate: bitrate ?? this.bitrate, - track: track, - ); - } - - /// Human-readable quality label for UI display - String get qualityLabel { - final parts = []; - - if (format.isNotEmpty) { - parts.add(format.toUpperCase()); - } - - if (bitDepth > 0 && sampleRate > 0) { - final srKhz = sampleRate >= 1000 - ? '${(sampleRate / 1000).toStringAsFixed(sampleRate % 1000 == 0 ? 0 : 1)}kHz' - : '${sampleRate}Hz'; - parts.add('$bitDepth-bit / $srKhz'); - } else if (bitrate > 0) { - parts.add('${bitrate}kbps'); - } - - return parts.join(' '); - } - - /// Whether this item has cover art that is a local file path - bool get hasLocalCover { - if (coverUrl.isEmpty) return false; - return !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); - } -} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 97c29817..545e7440 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,9 +11,6 @@ class AppSettings { final String storageMode; // 'app' or 'saf' final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; - final bool autoSkipUnavailableTracks; - final String playerMode; // 'internal' or 'external' - final bool smartQueueEnabled; // Enable smart curated autoplay queue final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedLyrics; final bool maxQualityCover; @@ -92,9 +89,6 @@ class AppSettings { this.storageMode = 'app', this.downloadTreeUri = '', this.autoFallback = true, - this.autoSkipUnavailableTracks = true, - this.playerMode = 'internal', - this.smartQueueEnabled = true, this.embedMetadata = true, this.embedLyrics = true, this.maxQualityCover = true, @@ -160,10 +154,7 @@ class AppSettings { String? downloadDirectory, String? storageMode, String? downloadTreeUri, - bool? autoFallback, - bool? autoSkipUnavailableTracks, - String? playerMode, - bool? smartQueueEnabled, + bool? autoFallback, bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, @@ -223,10 +214,6 @@ class AppSettings { storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, - autoSkipUnavailableTracks: - autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks, - playerMode: playerMode ?? this.playerMode, - smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled, embedMetadata: embedMetadata ?? this.embedMetadata, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index b484ee15..933178e3 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -14,9 +14,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( storageMode: json['storageMode'] as String? ?? 'app', downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, - autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true, - playerMode: json['playerMode'] as String? ?? 'internal', - smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true, @@ -93,9 +90,6 @@ Map _$AppSettingsToJson( 'storageMode': instance.storageMode, 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, - 'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks, - 'playerMode': instance.playerMode, - 'smartQueueEnabled': instance.smartQueueEnabled, 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 90863db5..c35f6a00 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -1,1218 +1,87 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:audio_service/audio_service.dart' as audio_service; -import 'package:audio_session/audio_session.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:spotiflac_android/models/playback_item.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; -import 'package:shared_preferences/shared_preferences.dart'; final _log = AppLogger('PlaybackProvider'); -// ─── Repeat mode ───────────────────────────────────────────────────────────── -enum RepeatMode { off, all, one } - -// ─── Lyrics types ──────────────────────────────────────────────────────────── - -/// A single word/syllable within a lyrics line, with its own timing. -class LyricsWord { - final String text; - final int startMs; - final int endMs; - - const LyricsWord({ - required this.text, - required this.startMs, - required this.endMs, - }); -} - -/// A single lyrics line, optionally with per-word timing. -class LyricsLine { - final int startMs; - final int endMs; - final String text; - final List words; - - const LyricsLine({ - required this.startMs, - required this.endMs, - required this.text, - this.words = const [], - }); - - bool get hasWordSync => words.isNotEmpty; -} - -/// Parsed lyrics data ready for display. -class LyricsData { - final List lines; - final String syncType; // LINE_SYNCED, UNSYNCED - final String source; // LRCLIB, Apple Music, etc. - final bool instrumental; - final bool isWordSynced; // true if any line has word-level timing - - const LyricsData({ - this.lines = const [], - this.syncType = '', - this.source = '', - this.instrumental = false, - this.isWordSynced = false, - }); - - bool get isSynced => syncType == 'LINE_SYNCED'; - bool get isEmpty => lines.isEmpty && !instrumental; -} - -// ─── State ─────────────────────────────────────────────────────────────────── class PlaybackState { - final PlaybackItem? currentItem; - final bool isPlaying; - final bool isBuffering; - final bool isLoading; - final Duration position; - final Duration bufferedPosition; - final Duration duration; - final String? error; - final String? errorType; - final bool seekSupported; - - // Queue - final List queue; - final int currentIndex; - final bool shuffle; - final RepeatMode repeatMode; - - // Lyrics - final LyricsData? lyrics; - final bool lyricsLoading; - - const PlaybackState({ - this.currentItem, - this.isPlaying = false, - this.isBuffering = false, - this.isLoading = false, - this.position = Duration.zero, - this.bufferedPosition = Duration.zero, - this.duration = Duration.zero, - this.error, - this.errorType, - this.seekSupported = true, - this.queue = const [], - this.currentIndex = -1, - this.shuffle = false, - this.repeatMode = RepeatMode.off, - this.lyrics, - this.lyricsLoading = false, - }); - - bool get hasNext => queue.isNotEmpty && currentIndex < queue.length - 1; - bool get hasPrevious => queue.isNotEmpty && currentIndex > 0; - - PlaybackState copyWith({ - PlaybackItem? currentItem, - bool clearCurrentItem = false, - bool? isPlaying, - bool? isBuffering, - bool? isLoading, - Duration? position, - Duration? bufferedPosition, - Duration? duration, - String? error, - String? errorType, - bool? seekSupported, - bool clearError = false, - List? queue, - int? currentIndex, - bool? shuffle, - RepeatMode? repeatMode, - LyricsData? lyrics, - bool clearLyrics = false, - bool? lyricsLoading, - }) { - return PlaybackState( - currentItem: clearCurrentItem ? null : (currentItem ?? this.currentItem), - isPlaying: isPlaying ?? this.isPlaying, - isBuffering: isBuffering ?? this.isBuffering, - isLoading: isLoading ?? this.isLoading, - position: position ?? this.position, - bufferedPosition: bufferedPosition ?? this.bufferedPosition, - duration: duration ?? this.duration, - error: clearError ? null : (error ?? this.error), - errorType: clearError ? null : (errorType ?? this.errorType), - seekSupported: seekSupported ?? this.seekSupported, - queue: queue ?? this.queue, - currentIndex: currentIndex ?? this.currentIndex, - shuffle: shuffle ?? this.shuffle, - repeatMode: repeatMode ?? this.repeatMode, - lyrics: clearLyrics ? null : (lyrics ?? this.lyrics), - lyricsLoading: lyricsLoading ?? this.lyricsLoading, - ); - } + const PlaybackState(); } -// ─── Audio Handler (audio_service bridge) ──────────────────────────────────── -class _SpotiFLACAudioHandler extends audio_service.BaseAudioHandler - with audio_service.SeekHandler { - final Future Function() _onPlay; - final Future Function() _onPause; - final Future Function() _onSkipNext; - final Future Function() _onSkipPrevious; - final Future Function() _onStop; - final Future Function(Duration position) _onSeek; - final Future Function() _onToggleLove; - - _SpotiFLACAudioHandler({ - required Future Function() onPlay, - required Future Function() onPause, - required Future Function() onSkipNext, - required Future Function() onSkipPrevious, - required Future Function() onStop, - required Future Function(Duration position) onSeek, - required Future Function() onToggleLove, - }) : _onPlay = onPlay, - _onPause = onPause, - _onSkipNext = onSkipNext, - _onSkipPrevious = onSkipPrevious, - _onStop = onStop, - _onSeek = onSeek, - _onToggleLove = onToggleLove; - - @override - Future customAction(String name, [Map? extras]) async { - if (name == 'toggle_love') { - try { - await _onToggleLove(); - } catch (e) { - _log.e('Notification toggle love failed: $e'); - } - } - return super.customAction(name, extras); - } - - @override - Future play() async { - try { - await _onPlay(); - } catch (e) { - _log.e('Notification play failed: $e'); - } - } - - @override - Future pause() async { - try { - await _onPause(); - } catch (e) { - _log.e('Notification pause failed: $e'); - } - } - - @override - Future seek(Duration position) => _onSeek(position); - - @override - Future stop() async { - try { - await _onStop(); - } catch (e) { - _log.e('Notification stop failed: $e'); - } - } - - @override - Future skipToNext() async { - try { - await _onSkipNext(); - } catch (e) { - _log.e('Notification next failed: $e'); - } - } - - @override - Future skipToPrevious() async { - try { - await _onSkipPrevious(); - } catch (e) { - _log.e('Notification previous failed: $e'); - } - } -} - -// ─── Controller ────────────────────────────────────────────────────────────── class PlaybackController extends Notifier { - static const String _playbackSnapshotKey = 'playback_snapshot_v1'; - static const String _smartQueueModelKey = 'smart_queue_model_v1'; - final AudioPlayer _player = AudioPlayer(); - final List> _subscriptions = []; - Timer? _snapshotSaveTimer; - Timer? _smartQueueModelSaveTimer; - _SpotiFLACAudioHandler? _audioHandler; - var _initialized = false; - static const Duration _prefetchThresholdFloor = Duration(seconds: 12); - static const Duration _prefetchThresholdCeiling = Duration(seconds: 40); - static const Duration _prefetchEarlyKickoffPosition = Duration(seconds: 6); - static const Duration _prefetchRetryCooldown = Duration(seconds: 3); - static const int _maxPrefetchAttemptsPerTrack = 2; - static const int _smartQueueTriggerRemainingTracks = 2; - static const int _smartQueueTargetRemainingTracks = 6; - static const int _smartQueueMaxAutoAddsPerSession = 40; - static const int _smartQueueRecentPlayedWindow = 40; - static const int _smartQueueCandidatePoolLimit = 28; - static const int _smartQueueOfflinePoolMaxItems = 1800; - static const int _smartQueueRelatedArtistsLimit = 3; - static const int _smartQueueMaxAffinityKeys = 160; - static const int _smartQueueSessionWindowSize = 10; - static const int _smartQueueMaxArtistRepeats = 2; - static const int _smartQueueMaxDecadeDriftYears = 20; - static const int _smartQueueMaxTempoJumpBpm = 42; - static const int _smartQueueMaxTempoHints = 720; - static const int _smartQueueMaxSkipStreak = 6; - static const double _smartQueuePrimarySourceRatio = 0.68; - static const String _smartQueueSpotifyExtensionId = 'spotify-web'; - static const Duration _smartQueueRefillCooldown = Duration(seconds: 18); - static const Duration _smartQueueSearchCacheTtl = Duration(minutes: 3); - static const Duration _smartQueueFeedbackMaxAge = Duration(hours: 6); - static const double _smartQueueLearningRate = 0.2; - int? _prefetchingQueueIndex; - int? _lastPrefetchAttemptIndex; - final Map _prefetchAttemptCounts = {}; - final Map _prefetchLastAttemptAt = {}; - final Map> _prefetchLatencyByServiceMs = - >{}; - final Random _smartQueueRandom = Random(); - final List _recentPlayedTrackKeys = []; - final Map - _smartQueuePendingFeedbackByTrack = {}; - final Map _smartQueueSearchCache = - {}; - final Map - _smartQueueRelatedArtistsCache = {}; - final Map _smartQueueWeights = { - 'bias': -0.15, - 'same_artist': 0.06, - 'same_album': 0.04, - 'duration_similarity': 0.8, - 'source_match': 0.18, - 'release_year_similarity': 0.32, - 'artist_affinity': 0.55, - 'source_affinity': 0.3, - 'novelty': 0.65, - 'session_alignment': 0.42, - 'hour_affinity': 0.21, - 'skip_context': 0.29, - 'tempo_continuity': 0.26, - 'year_cohesion': 0.22, - }; - final Map _smartQueueArtistAffinity = {}; - final Map _smartQueueSourceAffinity = {}; - final Map _smartQueueHourAffinity = {}; - final Map _smartQueueTempoHintByTrackKey = {}; - final List<_SmartQueueSessionSignal> _smartQueueSessionSignals = - <_SmartQueueSessionSignal>[]; - bool _smartQueueRefillInFlight = false; - DateTime? _lastSmartQueueRefillAt; - int _smartQueueAutoAddedCount = 0; - int _smartQueueSkipStreak = 0; - _SmartQueueSessionProfile _smartQueueSessionProfile = - const _SmartQueueSessionProfile( - mode: _SmartQueueSessionMode.balanced, - targetDurationSec: 215, - preferredSourceKey: '', - ); - - // Shuffle order: indices into queue - List _shuffleOrder = []; - int _shufflePosition = -1; - int _playRequestEpoch = 0; - Duration? _pendingResumePosition; - int? _pendingResumeIndex; - int _lastProgressSnapshotMs = -1; - int _lyricsGeneration = 0; - AppLifecycleListener? _appLifecycleListener; - @override - PlaybackState build() { - if (!_initialized) { - _initialized = true; - _init(); - ref.onDispose(_disposeInternal); - } - return const PlaybackState(); + PlaybackState build() => const PlaybackState(); + + Future playLocalPath({ + required String path, + required String title, + required String artist, + String album = '', + String coverUrl = '', + Track? track, + }) async { + _log.d('Opening external player for "$title" by $artist: $path'); + await openFile(path); } - void _init() { - unawaited(_configureAudioSession()); - unawaited(_initAudioService()); - unawaited(_restorePlaybackSnapshot()); - unawaited(_restoreSmartQueueModel()); - _appLifecycleListener ??= AppLifecycleListener( - onInactive: () => unawaited(_savePlaybackSnapshot()), - onPause: () => unawaited(_savePlaybackSnapshot()), - onDetach: () => unawaited(_savePlaybackSnapshot()), - onHide: () => unawaited(_savePlaybackSnapshot()), - ); + Future playTrackList(List tracks, {int startIndex = 0}) async { + if (tracks.isEmpty) return; - ref.listen(libraryCollectionsProvider, (previous, next) { - final track = state.currentItem?.track; - if (track != null) { - final wasLoved = previous?.isLoved(track) ?? false; - final isLoved = next.isLoved(track); - if (wasLoved != isLoved) { - _syncServicePlaybackState(_player.processingState, _player.playing); - } + final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + for (final track in orderedTracks) { + final resolvedPath = await _resolveTrackPath(track); + if (resolvedPath == null) { + continue; } - }); - ref.listen(settingsProvider.select((s) => s.playerMode), ( - previous, - next, - ) { - if (previous == next) return; - if (next != 'external') return; - final current = state.currentItem; - if (current == null || !current.isLocal) return; - unawaited(dismissPlayer()); - }); - - _subscriptions.add( - _player.playerStateStream.listen((playerState) { - final playing = playerState.playing; - final processingState = playerState.processingState; - - state = state.copyWith( - isPlaying: playing, - isBuffering: - processingState == ProcessingState.loading || - processingState == ProcessingState.buffering, - isLoading: false, - ); - - // Update audio_service playback state - _syncServicePlaybackState(processingState, playing); - - // Handle track completion - if (processingState == ProcessingState.completed) { - _onTrackCompleted(); - } - }), - ); - - _subscriptions.add( - _player - .createPositionStream( - minPeriod: const Duration(milliseconds: 16), - maxPeriod: const Duration(milliseconds: 33), - ) - .listen((position) { - final hasPendingResume = - state.currentIndex >= 0 && - _pendingResumePositionForIndex(state.currentIndex) != null; - final shouldKeepRestoredPosition = - _player.processingState == ProcessingState.idle && - hasPendingResume && - position == Duration.zero && - state.position > Duration.zero; - if (shouldKeepRestoredPosition) { - return; - } - state = state.copyWith(position: position); - _maybePrefetchNext(position); - _maybeTriggerSmartQueueRefill(position); - _scheduleSnapshotSaveForProgress(position); - }), - ); - - _subscriptions.add( - _player.bufferedPositionStream.listen((bufferedPosition) { - state = state.copyWith(bufferedPosition: bufferedPosition); - }), - ); - - _subscriptions.add( - _player.durationStream.listen((duration) { - final hasPendingResume = - state.currentIndex >= 0 && - _pendingResumePositionForIndex(state.currentIndex) != null; - final shouldKeepRestoredDuration = - _player.processingState == ProcessingState.idle && - hasPendingResume && - duration == null && - state.duration > Duration.zero; - if (shouldKeepRestoredDuration) { - return; - } - final fallbackDuration = _fallbackDurationForItem(state.currentItem); - final resolvedDuration = duration != null && duration > Duration.zero - ? duration - : fallbackDuration; - if (state.duration != resolvedDuration) { - state = state.copyWith(duration: resolvedDuration); - } - - if (duration != null && - duration > Duration.zero && - state.currentIndex >= 0 && - state.currentIndex < state.queue.length) { - final durationMs = duration.inMilliseconds; - final currentItem = state.currentItem; - final updatedCurrentItem = - currentItem != null && currentItem.durationMs != durationMs - ? PlaybackItem( - id: currentItem.id, - title: currentItem.title, - artist: currentItem.artist, - album: currentItem.album, - coverUrl: currentItem.coverUrl, - sourceUri: currentItem.sourceUri, - isLocal: currentItem.isLocal, - service: currentItem.service, - durationMs: durationMs, - format: currentItem.format, - bitDepth: currentItem.bitDepth, - sampleRate: currentItem.sampleRate, - bitrate: currentItem.bitrate, - track: currentItem.track, - ) - : currentItem; - - final queueItem = state.queue[state.currentIndex]; - final shouldUpdateQueueItem = queueItem.durationMs != durationMs; - - if (updatedCurrentItem != currentItem || shouldUpdateQueueItem) { - final updatedQueue = [...state.queue]; - if (shouldUpdateQueueItem) { - updatedQueue[state.currentIndex] = PlaybackItem( - id: queueItem.id, - title: queueItem.title, - artist: queueItem.artist, - album: queueItem.album, - coverUrl: queueItem.coverUrl, - sourceUri: queueItem.sourceUri, - isLocal: queueItem.isLocal, - service: queueItem.service, - durationMs: durationMs, - format: queueItem.format, - bitDepth: queueItem.bitDepth, - sampleRate: queueItem.sampleRate, - bitrate: queueItem.bitrate, - track: queueItem.track, - ); - } - - state = state.copyWith( - currentItem: updatedCurrentItem, - queue: updatedQueue, - ); - unawaited(_savePlaybackSnapshot()); - } - } - - // Update notification duration when known - if (state.currentItem != null && duration != null) { - _updateMediaItemNotification(state.currentItem!); - } - }), - ); - - _subscriptions.add( - _player.playbackEventStream.listen( - (_) {}, - onError: (Object error, StackTrace stackTrace) { - _log.e('Playback error: $error'); - state = state.copyWith( - isLoading: false, - isPlaying: false, - isBuffering: false, - error: error.toString(), - errorType: 'playback_failed', - ); - }, - ), - ); - } - - Future _initAudioService() async { - try { - _audioHandler = - await audio_service.AudioService.init<_SpotiFLACAudioHandler>( - builder: () => _SpotiFLACAudioHandler( - onPlay: _handleNotificationPlay, - onPause: _handleNotificationPause, - onSkipNext: _handleNotificationNext, - onSkipPrevious: _handleNotificationPrevious, - onStop: _handleNotificationStop, - onSeek: seek, - onToggleLove: _handleNotificationToggleLove, - ), - config: const audio_service.AudioServiceConfig( - androidNotificationChannelId: 'com.zarz.spotiflac.playback', - androidNotificationChannelName: 'Music Playback', - androidNotificationOngoing: true, - androidShowNotificationBadge: true, - androidStopForegroundOnPause: true, - ), - ); - } catch (e) { - _log.w('AudioService init failed: $e'); - } - } - - Future _configureAudioSession() async { - try { - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.music()); - } catch (e) { - _log.w('Audio session configuration failed: $e'); - } - } - - Future _handleNotificationPlay() async { - if (_player.processingState == ProcessingState.idle && - state.queue.isNotEmpty) { - final resumeIndex = state.currentIndex < 0 ? 0 : state.currentIndex; - await _playQueueIndex(resumeIndex); + _log.d( + 'Opening first available external track for list playback: ' + '"${track.name}" by ${track.artistName} -> $resolvedPath', + ); + await openFile(resolvedPath); return; } - await _player.play(); + + throw Exception( + 'No local audio file is available to open. Download the track first.', + ); } - Future _handleNotificationPause() async { - await _player.pause(); - } - - Future _handleNotificationNext() async { - await skipNext(); - } - - Future _handleNotificationPrevious() async { - await skipPrevious(); - } - - Future _handleNotificationStop() async { - await stop(); - } - - Future _handleNotificationToggleLove() async { - final track = state.currentItem?.track; - if (track != null) { - await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); - } - } - - void _syncServicePlaybackState( - ProcessingState processingState, - bool playing, - ) { - final handler = _audioHandler; - if (handler == null) return; - - audio_service.AudioProcessingState serviceState; - switch (processingState) { - case ProcessingState.idle: - serviceState = audio_service.AudioProcessingState.idle; - case ProcessingState.loading: - serviceState = audio_service.AudioProcessingState.loading; - case ProcessingState.buffering: - serviceState = audio_service.AudioProcessingState.buffering; - case ProcessingState.ready: - serviceState = audio_service.AudioProcessingState.ready; - case ProcessingState.completed: - serviceState = audio_service.AudioProcessingState.completed; + List _orderedTracksFromStartIndex(List tracks, int startIndex) { + final safeStart = startIndex.clamp(0, tracks.length - 1); + if (safeStart == 0) { + return List.from(tracks, growable: false); } - final track = state.currentItem?.track; - final isLoved = - track != null && ref.read(libraryCollectionsProvider).isLoved(track); - - final controls = [ - audio_service.MediaControl.custom( - androidIcon: isLoved - ? 'drawable/ic_stat_favorite' - : 'drawable/ic_stat_favorite_border', - label: isLoved ? 'Unlove' : 'Love', - name: 'toggle_love', - ), - audio_service.MediaControl.skipToPrevious, - if (playing) - audio_service.MediaControl.pause - else - audio_service.MediaControl.play, - audio_service.MediaControl.skipToNext, + return [ + ...tracks.sublist(safeStart), + ...tracks.sublist(0, safeStart), ]; - - final systemActions = {}; - if (state.seekSupported) { - systemActions.addAll(const { - audio_service.MediaAction.seek, - audio_service.MediaAction.seekForward, - audio_service.MediaAction.seekBackward, - }); - } - - handler.playbackState.add( - audio_service.PlaybackState( - controls: controls, - systemActions: systemActions, - androidCompactActionIndices: _compactIndices(controls), - processingState: serviceState, - playing: playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - ), - ); } - List _compactIndices(List controls) { - // Always show prev(0), play/pause(1), next(2) in compact notification - final count = controls.length; - if (count >= 3) return const [0, 1, 2]; - return List.generate(count, (i) => i); - } - - Uri? _resolveMediaArtUri(String coverUrl) { - final raw = coverUrl.trim(); - if (raw.isEmpty) return null; - - if (raw.startsWith('http://') || - raw.startsWith('https://') || - raw.startsWith('file://') || - raw.startsWith('content://')) { - return Uri.tryParse(raw); - } - - // Treat bare local paths as file URIs so notification can load local art. - return Uri.file(raw); - } - - void _updateMediaItemNotification(PlaybackItem item) { - final handler = _audioHandler; - if (handler == null) return; - - handler.mediaItem.add( - audio_service.MediaItem( - id: item.id, - album: item.album, - title: item.title, - artist: item.artist, - duration: state.duration, - artUri: _resolveMediaArtUri(item.coverUrl), - extras: { - if ((item.track?.isrc ?? '').trim().isNotEmpty) - 'isrc': item.track!.isrc!.trim(), - 'trackName': item.title, - 'artistName': item.artist, - if (item.album.isNotEmpty) 'albumName': item.album, - if (item.coverUrl.isNotEmpty) 'coverUrl': item.coverUrl, - if (item.sourceUri.isNotEmpty) 'sourceUri': item.sourceUri, - 'isLocal': item.isLocal, - if (item.service.isNotEmpty) 'service': item.service, - if (item.format.isNotEmpty) 'format': item.format, - }, - ), - ); - } - - // ─── Track completion ──────────────────────────────────────────────────── - void _onTrackCompleted() { - _learnFromCurrentTrackOutcome(completedNaturally: true); - final completedItem = state.currentItem; - if (completedItem != null) { - _rememberRecentPlayed(completedItem); - } - - if (state.repeatMode == RepeatMode.one) { - // Replay current track - unawaited(_restartCurrentTrack(playAfterSeek: true)); - return; - } - - final nextIndex = _resolveNextIndex(); - if (nextIndex != null) { - unawaited(_playQueueIndex(nextIndex)); - } else { - unawaited(_handleQueueExhausted()); - } - } - - Future _handleQueueExhausted() async { - final added = await _autoRefillSmartQueue(force: true); - if (added > 0) { - final nextIndex = _resolveNextIndex(); - if (nextIndex != null) { - await _playQueueIndex(nextIndex); - return; - } - } - - // Queue exhausted - state = state.copyWith(isPlaying: false, position: Duration.zero); - _syncServicePlaybackState(ProcessingState.completed, false); - } - - Future _restartCurrentTrack({bool playAfterSeek = false}) async { - try { - if (state.seekSupported) { - await _player.seek(Duration.zero); - if (playAfterSeek) { - await _player.play(); - } - return; - } - - final index = state.currentIndex; - if (index >= 0 && index < state.queue.length) { - await _playQueueIndex(index); - return; - } - - _setPlaybackError( - 'Failed to restart track from the beginning.', - type: 'playback_failed', - ); - } catch (e) { - _log.e('Failed to restart current track: $e'); - _setPlaybackError('Failed to restart track: $e', type: 'playback_failed'); - } - } - - int? _resolveNextIndex() { - if (state.queue.isEmpty) return null; - - if (state.shuffle) { - _shufflePosition++; - if (_shufflePosition < _shuffleOrder.length) { - return _shuffleOrder[_shufflePosition]; - } - // Shuffle exhausted - if (state.repeatMode == RepeatMode.all) { - _regenerateShuffleOrder(); - _shufflePosition = 0; - return _shuffleOrder.isNotEmpty ? _shuffleOrder[0] : null; - } - return null; - } - - final next = state.currentIndex + 1; - if (next < state.queue.length) return next; - if (state.repeatMode == RepeatMode.all) return 0; - return null; - } - - int? _resolvePreviousIndex() { - if (state.queue.isEmpty) return null; - - if (state.shuffle) { - if (_shufflePosition > 0) { - _shufflePosition--; - return _shuffleOrder[_shufflePosition]; - } - return null; - } - - final prev = state.currentIndex - 1; - if (prev >= 0) return prev; - if (state.repeatMode == RepeatMode.all) return state.queue.length - 1; - return null; - } - - void _regenerateShuffleOrder() { - final rng = Random(); - _shuffleOrder = List.generate(state.queue.length, (i) => i)..shuffle(rng); - } - - void _regenerateShuffleOrderPreservingCurrentProgress() { - final queueLength = state.queue.length; - if (queueLength == 0) { - _shuffleOrder = []; - _shufflePosition = -1; - return; - } - - final currentIndex = state.currentIndex; - if (currentIndex < 0 || currentIndex >= queueLength) { - _regenerateShuffleOrder(); - _shufflePosition = -1; - return; - } - - final rng = Random(); - final playedAndCurrent = List.generate(currentIndex + 1, (i) => i); - final upcoming = List.generate( - queueLength - currentIndex - 1, - (i) => currentIndex + i + 1, - )..shuffle(rng); - - _shuffleOrder = [...playedAndCurrent, ...upcoming]; - _shufflePosition = currentIndex; - } - - List getQueueDisplayOrder() { - if (state.queue.isEmpty) return const []; - - if (!state.shuffle) { - return List.generate(state.queue.length, (i) => i); - } - - final seen = {}; - final normalized = []; - for (final idx in _shuffleOrder) { - if (idx >= 0 && idx < state.queue.length && seen.add(idx)) { - normalized.add(idx); - } - } - for (var i = 0; i < state.queue.length; i++) { - if (seen.add(i)) { - normalized.add(i); - } - } - return normalized; - } - - int getCurrentDisplayQueuePosition({List? displayOrder}) { - final order = displayOrder ?? getQueueDisplayOrder(); - if (order.isEmpty) return -1; - - if (!state.shuffle) { - if (state.currentIndex < 0 || state.currentIndex >= order.length) { - return 0; - } - return state.currentIndex; - } - - final position = order.indexOf(state.currentIndex); - if (position >= 0) return position; - return 0; - } - - int _startNewPlayRequest() { - _playRequestEpoch++; - return _playRequestEpoch; - } - - void _resetPrefetchCycleState() { - _prefetchingQueueIndex = null; - _lastPrefetchAttemptIndex = null; - _prefetchAttemptCounts.clear(); - _prefetchLastAttemptAt.clear(); - } - - bool _isPlayRequestCurrent(int epoch) => epoch == _playRequestEpoch; - - void _clearLyricsForTrackChange({ - PlaybackItem? upcomingItem, - bool updateCurrentItem = true, - }) { - // Invalidate any in-flight lyrics fetch from previous track. - _lyricsGeneration++; - state = state.copyWith( - clearCurrentItem: !updateCurrentItem, - currentItem: updateCurrentItem - ? (upcomingItem ?? state.currentItem) - : null, - lyricsLoading: false, - clearLyrics: true, - ); - } - - Map _serializePlaybackItem(PlaybackItem item) => { - 'id': item.id, - 'title': item.title, - 'artist': item.artist, - 'album': item.album, - 'coverUrl': item.coverUrl, - 'sourceUri': item.sourceUri, - 'isLocal': item.isLocal, - 'service': item.service, - 'durationMs': item.durationMs, - 'format': item.format, - 'bitDepth': item.bitDepth, - 'sampleRate': item.sampleRate, - 'bitrate': item.bitrate, - if (item.track != null) 'track': item.track!.toJson(), - }; - - PlaybackItem? _deserializePlaybackItem(Map? json) { - if (json == null) return null; - final id = (json['id'] as String?)?.trim() ?? ''; - if (id.isEmpty) return null; - - Track? track; - try { - final trackJson = json['track']; - if (trackJson is Map) { - track = Track.fromJson(Map.from(trackJson)); - } - } catch (_) {} - - return PlaybackItem( - id: id, - title: (json['title'] as String?) ?? '', - artist: (json['artist'] as String?) ?? '', - album: (json['album'] as String?) ?? '', - coverUrl: (json['coverUrl'] as String?) ?? '', - sourceUri: (json['sourceUri'] as String?) ?? '', - isLocal: json['isLocal'] == true, - service: (json['service'] as String?) ?? '', - durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, - format: (json['format'] as String?) ?? '', - bitDepth: (json['bitDepth'] as num?)?.toInt() ?? 0, - sampleRate: (json['sampleRate'] as num?)?.toInt() ?? 0, - bitrate: (json['bitrate'] as num?)?.toInt() ?? 0, - track: track, - ); - } - - Future _savePlaybackSnapshot() async { - try { - final prefs = await SharedPreferences.getInstance(); - final payload = { - 'queue': state.queue - .map(_serializePlaybackItem) - .toList(growable: false), - 'currentIndex': state.currentIndex, - 'positionMs': state.position.inMilliseconds, - 'durationMs': state.duration > Duration.zero - ? state.duration.inMilliseconds - : (state.currentItem?.durationMs ?? 0), - 'shuffle': state.shuffle, - 'repeatMode': state.repeatMode.index, - }; - await prefs.setString(_playbackSnapshotKey, jsonEncode(payload)); - } catch (e) { - _log.w('Failed to save playback snapshot: $e'); - } - } - - Future _restorePlaybackSnapshot() async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_playbackSnapshotKey); - if (raw == null || raw.isEmpty) return; - - final decoded = jsonDecode(raw); - if (decoded is! Map) return; - final payload = Map.from(decoded); - - final queueRaw = payload['queue']; - final restoredQueue = []; - if (queueRaw is List) { - for (final entry in queueRaw) { - if (entry is! Map) continue; - final item = _deserializePlaybackItem( - Map.from(entry), - ); - if (item != null) restoredQueue.add(item); - } - } - if (restoredQueue.isEmpty) return; - - var restoredIndex = (payload['currentIndex'] as num?)?.toInt() ?? 0; - restoredIndex = restoredIndex.clamp(0, restoredQueue.length - 1).toInt(); - final restoredPositionMs = (payload['positionMs'] as num?)?.toInt() ?? 0; - final restoredDurationMs = (payload['durationMs'] as num?)?.toInt() ?? 0; - final restoredRepeatIndex = (payload['repeatMode'] as num?)?.toInt() ?? 0; - final restoredRepeatMode = - restoredRepeatIndex >= 0 && - restoredRepeatIndex < RepeatMode.values.length - ? RepeatMode.values[restoredRepeatIndex] - : RepeatMode.off; - - state = state.copyWith( - queue: restoredQueue, - currentIndex: restoredIndex, - currentItem: restoredQueue[restoredIndex], - isPlaying: false, - isBuffering: false, - isLoading: false, - position: Duration(milliseconds: restoredPositionMs), - bufferedPosition: Duration.zero, - duration: restoredDurationMs > 0 - ? Duration(milliseconds: restoredDurationMs) - : (restoredQueue[restoredIndex].durationMs > 0 - ? Duration( - milliseconds: restoredQueue[restoredIndex].durationMs, - ) - : Duration.zero), - shuffle: payload['shuffle'] == true, - repeatMode: restoredRepeatMode, - clearError: true, - ); - _pendingResumePosition = restoredPositionMs > 0 - ? Duration(milliseconds: restoredPositionMs) - : null; - _pendingResumeIndex = restoredPositionMs > 0 ? restoredIndex : null; - _lastProgressSnapshotMs = restoredPositionMs; - - if (state.shuffle) { - _regenerateShuffleOrder(); - _shufflePosition = _shuffleOrder.indexOf(state.currentIndex); - if (_shufflePosition < 0) _shufflePosition = 0; - } else { - _shuffleOrder = []; - _shufflePosition = -1; - } - } catch (e) { - _log.w('Failed to restore playback snapshot: $e'); - } - } - - Future _restoreSmartQueueModel() async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_smartQueueModelKey); - if (raw == null || raw.isEmpty) return; - - final decoded = jsonDecode(raw); - if (decoded is! Map) return; - final payload = Map.from(decoded); - - final weightsRaw = payload['weights']; - if (weightsRaw is Map) { - for (final entry in weightsRaw.entries) { - final key = entry.key.toString(); - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueWeights[key] = value; - } - } - - _smartQueueArtistAffinity.clear(); - final artistRaw = payload['artistAffinity']; - if (artistRaw is Map) { - for (final entry in artistRaw.entries) { - final key = entry.key.toString().trim().toLowerCase(); - if (key.isEmpty) continue; - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueArtistAffinity[key] = value.clamp(-1.0, 1.0); - } - } - - _smartQueueSourceAffinity.clear(); - final sourceRaw = payload['sourceAffinity']; - if (sourceRaw is Map) { - for (final entry in sourceRaw.entries) { - final key = entry.key.toString().trim().toLowerCase(); - if (key.isEmpty) continue; - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueSourceAffinity[key] = value.clamp(-1.0, 1.0); - } - } - - _smartQueueHourAffinity.clear(); - final hourRaw = payload['hourAffinity']; - if (hourRaw is Map) { - for (final entry in hourRaw.entries) { - final key = entry.key.toString().trim().toLowerCase(); - if (key.isEmpty) continue; - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueHourAffinity[key] = value.clamp(-1.0, 1.0); - } - } - } catch (e) { - _log.w('Failed to restore smart queue model: $e'); - } - } - - void _scheduleSmartQueueModelSave() { - _smartQueueModelSaveTimer?.cancel(); - _smartQueueModelSaveTimer = Timer(const Duration(seconds: 2), () { - unawaited(_persistSmartQueueModel()); - }); - } - - Future _persistSmartQueueModel() async { - try { - final prefs = await SharedPreferences.getInstance(); - final payload = { - 'weights': _smartQueueWeights, - 'artistAffinity': _smartQueueArtistAffinity, - 'sourceAffinity': _smartQueueSourceAffinity, - 'hourAffinity': _smartQueueHourAffinity, - }; - await prefs.setString(_smartQueueModelKey, jsonEncode(payload)); - } catch (e) { - _log.w('Failed to save smart queue model: $e'); - } - } - - PlaybackItem _buildQueueItemFromTrack(Track track) { + Future _resolveTrackPath(Track track) async { final localState = ref.read(localLibraryProvider); final historyState = ref.read(downloadHistoryProvider); - final localItem = _findLocalLibraryItemForTrack(track, localState); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - if (localItem != null && localItem.filePath.isNotEmpty) { - final localUri = _uriFromPath(localItem.filePath); - final localDurationMs = - localItem.duration != null && localItem.duration! > 0 - ? localItem.duration! * 1000 - : _trackDurationMs(track); - return PlaybackItem( - id: localItem.id, - title: localItem.trackName, - artist: localItem.artistName, - album: localItem.albumName, - coverUrl: localItem.coverPath ?? track.coverUrl ?? '', - sourceUri: localUri.toString(), - isLocal: true, - service: 'offline', - durationMs: localDurationMs, - track: track, - ); + final localItem = _findLocalLibraryItemForTrack(track, localState); + if (localItem != null && await fileExists(localItem.filePath)) { + return localItem.filePath; } final historyItem = _findDownloadHistoryItemForTrack(track, historyState); - if (historyItem != null && historyItem.filePath.isNotEmpty) { - final localUri = _uriFromPath(historyItem.filePath); - final localDurationMs = - historyItem.duration != null && historyItem.duration! > 0 - ? historyItem.duration! * 1000 - : _trackDurationMs(track); - final playbackId = (historyItem.spotifyId ?? '').trim().isNotEmpty - ? historyItem.spotifyId!.trim() - : historyItem.id; - return PlaybackItem( - id: playbackId, - title: historyItem.trackName, - artist: historyItem.artistName, - album: historyItem.albumName, - coverUrl: historyItem.coverUrl ?? track.coverUrl ?? '', - sourceUri: localUri.toString(), - isLocal: true, - service: 'offline', - durationMs: localDurationMs, - track: track, - ); + if (historyItem != null) { + if (await fileExists(historyItem.filePath)) { + return historyItem.filePath; + } + historyNotifier.removeFromHistory(historyItem.id); } - return PlaybackItem( - id: track.id, - title: track.name, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', - sourceUri: '', - durationMs: _trackDurationMs(track), - track: track, - ); + return null; } LocalLibraryItem? _findLocalLibraryItemForTrack( @@ -1231,7 +100,9 @@ class PlaybackController extends Notifier { final isrc = track.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) { final byIsrc = localState.getByIsrc(isrc); - if (byIsrc != null) return byIsrc; + if (byIsrc != null) { + return byIsrc; + } } return localState.findByTrackAndArtist(track.name, track.artistName); @@ -1243,13 +114,17 @@ class PlaybackController extends Notifier { ) { for (final candidateId in _spotifyIdLookupCandidates(track.id)) { final bySpotifyId = historyState.getBySpotifyId(candidateId); - if (bySpotifyId != null) return bySpotifyId; + if (bySpotifyId != null) { + return bySpotifyId; + } } final isrc = track.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) { final byIsrc = historyState.getByIsrc(isrc); - if (byIsrc != null) return byIsrc; + if (byIsrc != null) { + return byIsrc; + } } return historyState.findByTrackAndArtist(track.name, track.artistName); @@ -1257,13 +132,17 @@ class PlaybackController extends Notifier { List _spotifyIdLookupCandidates(String rawId) { final trimmed = rawId.trim(); - if (trimmed.isEmpty) return const []; + if (trimmed.isEmpty) { + return const []; + } final candidates = {trimmed}; final lowered = trimmed.toLowerCase(); if (lowered.startsWith('spotify:track:')) { final compact = trimmed.split(':').last.trim(); - if (compact.isNotEmpty) candidates.add(compact); + if (compact.isNotEmpty) { + candidates.add(compact); + } } else if (!trimmed.contains(':')) { candidates.add('spotify:track:$trimmed'); } @@ -1281,3258 +160,6 @@ class PlaybackController extends Notifier { return candidates.toList(growable: false); } - - int _trackDurationMs(Track track) { - if (track.duration <= 0) return 0; - return track.duration * 1000; - } - - Duration _fallbackDurationForItem(PlaybackItem? item) { - final ms = item?.durationMs ?? 0; - if (ms <= 0) return Duration.zero; - return Duration(milliseconds: ms); - } - - // ─── Public: play local file ───────────────────────────────────────────── - Future playLocalPath({ - required String path, - required String title, - required String artist, - String album = '', - String coverUrl = '', - Track? track, - }) async { - final requestEpoch = _startNewPlayRequest(); - _resetPrefetchCycleState(); - _resetSmartQueueSessionState(clearRecent: true); - _pendingResumePosition = null; - _pendingResumeIndex = null; - final uri = _uriFromPath(path); - final fallbackTrack = Track( - id: path, - name: title, - artistName: artist, - albumName: album, - coverUrl: coverUrl.isNotEmpty ? coverUrl : null, - duration: 0, - source: 'local', - ); - final item = PlaybackItem( - id: path, - title: title, - artist: artist, - album: album, - coverUrl: coverUrl, - sourceUri: uri.toString(), - isLocal: true, - service: 'offline', - track: track ?? fallbackTrack, - ); - - final routeToExternal = _shouldRouteToExternalPlayer(item); - _clearLyricsForTrackChange( - upcomingItem: item, - updateCurrentItem: !routeToExternal, - ); - - if (routeToExternal) { - state = state.copyWith( - clearCurrentItem: true, - queue: const [], - currentIndex: -1, - isLoading: false, - isBuffering: false, - isPlaying: false, - seekSupported: false, - position: Duration.zero, - bufferedPosition: Duration.zero, - duration: Duration.zero, - clearError: true, - ); - unawaited(_savePlaybackSnapshot()); - await _setSourceAndPlay(uri, item, expectedRequestEpoch: requestEpoch); - return; - } - - // Replacing single-track playback should also replace queue to avoid stale UI. - state = state.copyWith( - seekSupported: true, - clearError: true, - queue: [item], - currentIndex: 0, - ); - unawaited(_savePlaybackSnapshot()); - - if (state.shuffle) { - _regenerateShuffleOrder(); - _shufflePosition = _shuffleOrder.indexOf(0); - if (_shufflePosition < 0) _shufflePosition = 0; - } else { - _shuffleOrder = []; - _shufflePosition = -1; - } - - await _setSourceAndPlay(uri, item, expectedRequestEpoch: requestEpoch); - } - - // ─── Public: play a list of tracks (set queue) ─────────────────────────── - Future playTrackList(List tracks, {int startIndex = 0}) async { - if (tracks.isEmpty) return; - _resetPrefetchCycleState(); - _resetSmartQueueSessionState(clearRecent: true); - - final items = tracks.map(_buildQueueItemFromTrack).toList(growable: false); - _pendingResumePosition = null; - _pendingResumeIndex = null; - - state = state.copyWith( - queue: items, - currentIndex: startIndex.clamp(0, items.length - 1), - ); - unawaited(_savePlaybackSnapshot()); - - if (state.shuffle) { - _regenerateShuffleOrder(); - // Place the starting track at the front of the shuffle order - // so playback begins from it, then continues in random order. - final pos = _shuffleOrder.indexOf(state.currentIndex); - if (pos > 0) { - _shuffleOrder.removeAt(pos); - _shuffleOrder.insert(0, state.currentIndex); - } - _shufflePosition = 0; - } - - await _playQueueIndex(state.currentIndex); - } - - // ─── Public: add track to queue ────────────────────────────────────────── - void addToQueue(Track track) { - final item = _buildQueueItemFromTrack(track); - - final newQueue = [...state.queue, item]; - state = state.copyWith(queue: newQueue); - unawaited(_savePlaybackSnapshot()); - - if (state.shuffle) { - _shuffleOrder.add(newQueue.length - 1); - } - } - - // ─── Public: remove from queue ─────────────────────────────────────────── - void removeFromQueue(int index) { - if (index < 0 || index >= state.queue.length) return; - - final newQueue = [...state.queue]..removeAt(index); - var newIndex = state.currentIndex; - if (index < newIndex) { - newIndex--; - } else if (index == newIndex) { - newIndex = newIndex.clamp(0, newQueue.length - 1); - } - - state = state.copyWith(queue: newQueue, currentIndex: newIndex); - unawaited(_savePlaybackSnapshot()); - if (state.shuffle) _regenerateShuffleOrder(); - } - - // ─── Public: clear queue ───────────────────────────────────────────────── - void clearQueue() { - _resetPrefetchCycleState(); - _resetSmartQueueSessionState(clearRecent: false); - _lastProgressSnapshotMs = -1; - state = state.copyWith(queue: [], currentIndex: -1); - unawaited(_savePlaybackSnapshot()); - _shuffleOrder = []; - _shufflePosition = -1; - _pendingResumePosition = null; - _pendingResumeIndex = null; - } - - // ─── Public: jump to specific queue index ──────────────────────────────── - Future playQueueIndex(int index) async { - if (index < 0 || index >= state.queue.length) return; - if (index == state.currentIndex) return; - await _playQueueIndex(index); - } - - // ─── Public: skip next / previous ──────────────────────────────────────── - Future skipNext() async { - _learnFromCurrentTrackOutcome(completedNaturally: false); - final nextIndex = _resolveNextIndex(); - if (nextIndex != null) { - await _playQueueIndex(nextIndex); - } - } - - Future skipPrevious() async { - // If > 3 seconds into track, restart instead of going previous - if (_player.position.inSeconds > 3) { - await _restartCurrentTrack(); - return; - } - - final prevIndex = _resolvePreviousIndex(); - if (prevIndex != null) { - await _playQueueIndex(prevIndex); - } else { - await _restartCurrentTrack(); - } - } - - // ─── Public: toggle shuffle ────────────────────────────────────────────── - void toggleShuffle() { - final newShuffle = !state.shuffle; - state = state.copyWith(shuffle: newShuffle); - - if (newShuffle) { - _regenerateShuffleOrderPreservingCurrentProgress(); - } else { - _shuffleOrder = []; - _shufflePosition = -1; - } - unawaited(_savePlaybackSnapshot()); - } - - // ─── Public: cycle repeat mode ─────────────────────────────────────────── - void cycleRepeatMode() { - final modes = RepeatMode.values; - final next = (state.repeatMode.index + 1) % modes.length; - state = state.copyWith(repeatMode: modes[next]); - } - - // ─── Public: toggle play/pause ─────────────────────────────────────────── - Future togglePlayPause() async { - if (_player.playing) { - await _player.pause(); - } else { - if (_player.processingState == ProcessingState.completed) { - final hasCurrentTrack = - state.currentIndex >= 0 || state.currentItem != null; - if (hasCurrentTrack) { - await _restartCurrentTrack(playAfterSeek: true); - return; - } - } - - if (_player.processingState == ProcessingState.idle && - state.queue.isNotEmpty) { - final resumeIndex = state.currentIndex < 0 ? 0 : state.currentIndex; - await _playQueueIndex(resumeIndex); - return; - } - await _player.play(); - } - } - - // ─── Public: seek ──────────────────────────────────────────────────────── - Future seek(Duration position) async { - if (!state.seekSupported) { - _setPlaybackError( - 'Seeking is not supported for this stream.', - type: 'seek_not_supported', - ); - return; - } - await _player.seek(position); - } - - // ─── Public: stop ──────────────────────────────────────────────────────── - Future stop() async { - _startNewPlayRequest(); - _lyricsGeneration++; - final lastKnownPosition = state.position; - final lastKnownDuration = state.duration; - await FFmpegService.stopLiveDecryptedStream(); - await FFmpegService.stopNativeDashManifestPlayback(); - await FFmpegService.cleanupInactivePreparedNativeDashManifests(); - await _player.stop(); - _resetPrefetchCycleState(); - _lastProgressSnapshotMs = lastKnownPosition.inMilliseconds; - _audioHandler?.playbackState.add( - audio_service.PlaybackState( - processingState: audio_service.AudioProcessingState.idle, - playing: false, - ), - ); - _audioHandler?.mediaItem.add(null); - - state = state.copyWith( - isPlaying: false, - isBuffering: false, - isLoading: false, - seekSupported: true, - position: lastKnownPosition, - bufferedPosition: Duration.zero, - duration: lastKnownDuration, - clearError: true, - clearLyrics: true, - ); - unawaited(_savePlaybackSnapshot()); - } - - /// Stops playback and dismisses the mini player UI entirely. - Future dismissPlayer() async { - await stop(); - _pendingResumePosition = null; - _pendingResumeIndex = null; - _lastProgressSnapshotMs = -1; - - state = state.copyWith( - clearCurrentItem: true, - queue: const [], - currentIndex: -1, - position: Duration.zero, - bufferedPosition: Duration.zero, - duration: Duration.zero, - clearError: true, - clearLyrics: true, - lyricsLoading: false, - ); - - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_playbackSnapshotKey); - } catch (e) { - _log.w('Failed to clear playback snapshot on dismiss: $e'); - } - } - - void clearError() { - state = state.copyWith(clearError: true); - } - - // ─── Internal ──────────────────────────────────────────────────────────── - - Future _playQueueIndex(int index) async { - if (index < 0 || index >= state.queue.length) return; - - final previousItem = state.currentItem; - final requestEpoch = _startNewPlayRequest(); - _resetPrefetchCycleState(); - final pendingResumePosition = _pendingResumePositionForIndex(index); - final item = state.queue[index]; - if (previousItem != null && - _trackKeyFromPlaybackItem(previousItem) != - _trackKeyFromPlaybackItem(item)) { - _rememberRecentPlayed(previousItem); - } - final routeToExternal = _shouldRouteToExternalPlayer(item); - _clearLyricsForTrackChange( - upcomingItem: item, - updateCurrentItem: !routeToExternal, - ); - state = state.copyWith( - currentIndex: index, - clearCurrentItem: routeToExternal, - currentItem: routeToExternal ? null : item, - isLoading: routeToExternal ? false : true, - isBuffering: routeToExternal ? false : true, - isPlaying: false, - seekSupported: routeToExternal - ? false - : _inferSeekSupportedForQueueItem(item), - position: routeToExternal - ? Duration.zero - : (pendingResumePosition != null && - pendingResumePosition > Duration.zero - ? pendingResumePosition - : Duration.zero), - bufferedPosition: Duration.zero, - duration: routeToExternal - ? Duration.zero - : _fallbackDurationForItem(item), - clearError: true, - ); - await _savePlaybackSnapshot(); - - if (item.sourceUri.isEmpty) { - final skipped = await _handleQueueItemPlaybackFailure( - failedIndex: index, - expectedRequestEpoch: requestEpoch, - error: Exception('Track is not available locally. Download it first.'), - fallbackType: 'source_missing', - ); - if (skipped) { - return; - } - return; - } - - // Already have a URI - if (item.sourceUri.isNotEmpty) { - final uri = _uriFromPath(item.sourceUri); - try { - await _setSourceAndPlay( - uri, - item, - initialPosition: pendingResumePosition, - expectedRequestEpoch: requestEpoch, - ); - if (!_isPlayRequestCurrent(requestEpoch) || - (!routeToExternal && state.currentIndex != index)) { - return; - } - _clearPendingResumeForIndex(index); - } catch (e) { - if (!_isPlayRequestCurrent(requestEpoch)) return; - final skipped = await _handleQueueItemPlaybackFailure( - failedIndex: index, - expectedRequestEpoch: requestEpoch, - error: e, - fallbackType: 'playback_failed', - ); - if (skipped) { - return; - } - } - } - } - - Future _setSourceAndPlay( - Uri uri, - PlaybackItem item, { - Duration? initialPosition, - int? expectedRequestEpoch, - }) async { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - - final handledByExternal = await _tryPlayWithExternalPlayerIfConfigured( - uri: uri, - item: item, - expectedRequestEpoch: expectedRequestEpoch, - ); - if (handledByExternal) { - return; - } - - final sourceUrl = uri.toString(); - await FFmpegService.activatePreparedNativeDashManifest(sourceUrl); - if (!FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { - await FFmpegService.stopLiveDecryptedStream(); - } - if (!FFmpegService.isActiveNativeDashManifestUrl(sourceUrl)) { - await FFmpegService.stopNativeDashManifestPlayback(); - } - - final startPosition = - initialPosition != null && initialPosition > Duration.zero - ? initialPosition - : Duration.zero; - state = state.copyWith( - currentItem: item, - isLoading: true, - isBuffering: true, - isPlaying: false, - position: startPosition, - bufferedPosition: Duration.zero, - duration: _fallbackDurationForItem(item), - clearError: true, - ); - unawaited(_savePlaybackSnapshot()); - - _updateMediaItemNotification(item); - - try { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - final isDirectLocalFile = uri.scheme == 'file'; - if (isDirectLocalFile) { - final filePath = uri.toFilePath(); - if (startPosition > Duration.zero) { - await _player.setFilePath(filePath, initialPosition: startPosition); - } else { - await _player.setFilePath(filePath); - } - } else { - if (startPosition > Duration.zero) { - await _player.setAudioSource( - AudioSource.uri(uri), - initialPosition: startPosition, - ); - } else { - await _player.setAudioSource(AudioSource.uri(uri)); - } - } - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - await _player.play(); - } catch (e) { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - if (FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { - await FFmpegService.stopLiveDecryptedStream(); - } - if (FFmpegService.isActiveNativeDashManifestUrl(sourceUrl)) { - await FFmpegService.stopNativeDashManifestPlayback(); - } - _log.e('Failed to play source: $e'); - _setPlaybackError(e.toString(), type: 'playback_failed'); - rethrow; - } - } - - Future _tryPlayWithExternalPlayerIfConfigured({ - required Uri uri, - required PlaybackItem item, - int? expectedRequestEpoch, - }) async { - if (!_shouldRouteToExternalPlayer(item)) return false; - - final externalPath = _externalPathFromPlaybackUri(uri); - if (externalPath == null || externalPath.isEmpty) return false; - - _log.d('Opening with external player: $externalPath'); - _updateMediaItemNotification(item); - - try { - await FFmpegService.stopLiveDecryptedStream(); - await FFmpegService.stopNativeDashManifestPlayback(); - await _player.stop(); - await openFile(externalPath); - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return true; - } - state = state.copyWith( - clearCurrentItem: true, - queue: const [], - currentIndex: -1, - isLoading: false, - isBuffering: false, - isPlaying: false, - seekSupported: false, - position: Duration.zero, - bufferedPosition: Duration.zero, - duration: Duration.zero, - clearError: true, - clearLyrics: true, - lyricsLoading: false, - ); - _syncServicePlaybackState(ProcessingState.idle, false); - unawaited(_savePlaybackSnapshot()); - return true; - } catch (e) { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return true; - } - _log.w('External player open failed: $e'); - state = state.copyWith( - isLoading: false, - isBuffering: false, - isPlaying: false, - ); - _setPlaybackError( - 'Failed to open in external player: $e', - type: 'external_player_failed', - ); - return true; - } - } - - bool _shouldRouteToExternalPlayer(PlaybackItem item) { - final settings = ref.read(settingsProvider); - return settings.playerMode == 'external' && item.isLocal; - } - - String? _externalPathFromPlaybackUri(Uri uri) { - if (uri.scheme == 'content') { - return uri.toString(); - } - if (uri.scheme == 'file') { - try { - return uri.toFilePath(); - } catch (_) { - return uri.path.isNotEmpty ? uri.path : null; - } - } - if (!uri.hasScheme) { - final asString = uri.toString().trim(); - return asString.isNotEmpty ? asString : null; - } - return null; - } - - // ─── Lyrics fetching + parsing ─────────────────────────────────────────── - - Future _fetchLyricsForItem(PlaybackItem item) async { - final generation = ++_lyricsGeneration; - _log.d('Lyrics fetch start: ${item.artist} - ${item.title} (${item.id})'); - state = state.copyWith(lyricsLoading: true, clearLyrics: true); - - try { - final localLyrics = await _tryLoadLocalLyricsForItem(item); - if (generation != _lyricsGeneration) return; - if (localLyrics != null) { - _log.d( - 'Lyrics loaded from local source: ${localLyrics.source} (sync=${localLyrics.syncType}, lines=${localLyrics.lines.length}, wordSync=${localLyrics.isWordSynced})', - ); - state = state.copyWith(lyricsLoading: false, lyrics: localLyrics); - return; - } - - final result = await PlatformBridge.fetchLyrics( - item.id, - item.title, - item.artist, - durationMs: item.durationMs, - ); - - // Discard if a newer track has started since - if (generation != _lyricsGeneration) return; - - final success = result['success'] == true; - final instrumental = result['instrumental'] == true; - final syncType = (result['sync_type'] as String?) ?? ''; - final source = (result['source'] as String?) ?? ''; - - if (!success && !instrumental) { - _log.d('Lyrics fetch returned no usable lyrics for ${item.id}'); - state = state.copyWith( - lyricsLoading: false, - lyrics: const LyricsData(), - ); - return; - } - - if (instrumental) { - _log.d('Lyrics fetch result is instrumental from: $source'); - state = state.copyWith( - lyricsLoading: false, - lyrics: LyricsData( - instrumental: true, - source: source, - syncType: syncType, - ), - ); - return; - } - - final rawLines = result['lines'] as List? ?? []; - final parsed = _parseLyricsLines(rawLines, syncType); - _log.d( - 'Lyrics fetch success from $source (sync=$syncType, lines=${parsed.lines.length}, wordSync=${parsed.hasWordSync})', - ); - - state = state.copyWith( - lyricsLoading: false, - lyrics: LyricsData( - lines: parsed.lines, - syncType: syncType, - source: source, - isWordSynced: parsed.hasWordSync, - ), - ); - } catch (e) { - if (generation != _lyricsGeneration) return; - _log.w('Lyrics fetch failed for ${item.id}: $e'); - state = state.copyWith(lyricsLoading: false, lyrics: const LyricsData()); - } - } - - /// Public method to manually refetch lyrics (e.g. retry button). - Future refetchLyrics() async { - await ensureLyricsLoaded(force: true); - } - - /// Load lyrics only when needed (e.g. when lyrics page is visible). - Future ensureLyricsLoaded({bool force = false}) async { - final item = state.currentItem; - if (item == null) return; - final lifecycleState = WidgetsBinding.instance.lifecycleState; - if (!force && - lifecycleState != null && - lifecycleState != AppLifecycleState.resumed) { - return; - } - if (!force) { - if (state.lyricsLoading) return; - if (state.lyrics != null) return; - } - await _fetchLyricsForItem(item); - } - - Future _tryLoadLocalLyricsForItem(PlaybackItem item) async { - final localPath = _resolveLocalLyricsLookupPath(item); - if (localPath == null) return null; - - try { - final result = await PlatformBridge.getLyricsLRCWithSource( - item.id, - item.title, - item.artist, - filePath: localPath, - durationMs: item.durationMs, - ); - return _lyricsDataFromLrcLookupResult(result); - } catch (e) { - _log.d('Local lyrics lookup skipped for ${item.id}: $e'); - return null; - } - } - - String? _resolveLocalLyricsLookupPath(PlaybackItem item) { - if (!item.isLocal) return null; - final sourceUri = item.sourceUri.trim(); - if (sourceUri.isEmpty) return null; - if (sourceUri.startsWith('content://')) return sourceUri; - if (sourceUri.startsWith('/')) return sourceUri; - - final uri = Uri.tryParse(sourceUri); - if (uri == null) return null; - if (uri.scheme == 'content') return sourceUri; - if (uri.scheme == 'file') { - try { - return uri.toFilePath(); - } catch (_) { - return uri.path.isNotEmpty ? uri.path : null; - } - } - return null; - } - - LyricsData? _lyricsDataFromLrcLookupResult(Map result) { - final rawLyrics = (result['lyrics'] as String?)?.trim() ?? ''; - final sourceRaw = (result['source'] as String?)?.trim() ?? ''; - final syncTypeRaw = (result['sync_type'] as String?)?.trim().toUpperCase(); - final instrumental = - result['instrumental'] == true || rawLyrics == '[instrumental:true]'; - final source = sourceRaw.isNotEmpty ? sourceRaw : 'Embedded'; - - if (instrumental) { - final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' - ? syncTypeRaw! - : 'UNSYNCED'; - return LyricsData(instrumental: true, source: source, syncType: syncType); - } - if (rawLyrics.isEmpty) return null; - - final parsed = _parseLrcLyrics(rawLyrics); - if (parsed.lines.isEmpty) return null; - final effectiveSyncType = parsed.hasTimedLines ? 'LINE_SYNCED' : 'UNSYNCED'; - final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' - ? syncTypeRaw! - : effectiveSyncType; - return LyricsData( - lines: parsed.lines, - syncType: syncType, - source: source, - isWordSynced: parsed.hasWordSync, - ); - } - - /// Parse raw lines from Go backend into [LyricsLine] list. - static ({List lines, bool hasWordSync}) _parseLyricsLines( - List rawLines, - String syncType, - ) { - final lines = []; - var hasAnyWordSync = false; - - for (var i = 0; i < rawLines.length; i++) { - final raw = rawLines[i] as Map; - final startMs = (raw['startTimeMs'] as num?)?.toInt() ?? 0; - final endMs = (raw['endTimeMs'] as num?)?.toInt() ?? 0; - final wordsRaw = (raw['words'] as String?) ?? ''; - - // Strip voice tags (v1:, v2:) from the beginning - var cleanedText = wordsRaw; - if (cleanedText.startsWith('v1:') || cleanedText.startsWith('v2:')) { - cleanedText = cleanedText.substring(3); - } - - // Parse word-by-word inline timestamps: word - final words = _parseInlineWordTimestamps(cleanedText, startMs); - if (words.isNotEmpty) hasAnyWordSync = true; - - // Clean text for display (remove inline timestamps) - final displayText = _stripInlineTimestamps(cleanedText); - - // Calculate end time: use provided endMs, or next line's start, or +5s - var effectiveEnd = endMs; - if (effectiveEnd <= startMs && i + 1 < rawLines.length) { - final nextStart = - (rawLines[i + 1] as Map)['startTimeMs'] as num?; - effectiveEnd = nextStart?.toInt() ?? (startMs + 5000); - } - if (effectiveEnd <= startMs) effectiveEnd = startMs + 5000; - - lines.add( - LyricsLine( - startMs: startMs, - endMs: effectiveEnd, - text: displayText.trim(), - words: words, - ), - ); - } - - return (lines: lines, hasWordSync: hasAnyWordSync); - } - - static final RegExp _lrcLineTimestampPattern = RegExp( - r'\[(\d{2}):(\d{2})\.(\d{2,3})\]', - ); - static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); - static final RegExp _lrcSpeakerPrefixPattern = RegExp( - r'^(v1|v2):\s*', - caseSensitive: false, - ); - - static ({List lines, bool hasWordSync, bool hasTimedLines}) - _parseLrcLyrics(String lrcText) { - final timed = []; - final unsyncedTexts = []; - var hasAnyWordSync = false; - - for (final rawLine in lrcText.split('\n')) { - final trimmed = rawLine.trim(); - if (trimmed.isEmpty || trimmed == '[instrumental:true]') continue; - - final timestamps = _lrcLineTimestampPattern.allMatches(trimmed).toList(); - if (timestamps.isEmpty) { - if (_lrcMetadataPattern.hasMatch(trimmed)) continue; - final unsynced = _stripInlineTimestamps( - trimmed.replaceFirst(_lrcSpeakerPrefixPattern, ''), - ); - if (unsynced.isNotEmpty) { - unsyncedTexts.add(unsynced); - } - continue; - } - - final timedText = trimmed - .replaceAll(_lrcLineTimestampPattern, '') - .replaceFirst(_lrcSpeakerPrefixPattern, '') - .trim(); - final displayText = _stripInlineTimestamps(timedText); - if (displayText.isEmpty) continue; - - for (final match in timestamps) { - final startMs = _lrcInlineToMs( - match.group(1)!, - match.group(2)!, - match.group(3)!, - ); - final words = _parseInlineWordTimestamps(timedText, startMs); - if (words.isNotEmpty) hasAnyWordSync = true; - timed.add( - LyricsLine( - startMs: startMs, - endMs: startMs + 5000, - text: displayText, - words: words, - ), - ); - } - } - - if (timed.isNotEmpty) { - timed.sort((a, b) => a.startMs.compareTo(b.startMs)); - final normalized = []; - for (var i = 0; i < timed.length; i++) { - final current = timed[i]; - final nextStart = i + 1 < timed.length - ? timed[i + 1].startMs - : current.startMs + 5000; - final endMs = nextStart > current.startMs - ? nextStart - : current.startMs + 5000; - normalized.add( - LyricsLine( - startMs: current.startMs, - endMs: endMs, - text: current.text, - words: current.words, - ), - ); - } - return ( - lines: normalized, - hasWordSync: hasAnyWordSync, - hasTimedLines: true, - ); - } - - final unsynced = unsyncedTexts - .map((text) => LyricsLine(startMs: 0, endMs: 0, text: text)) - .toList(growable: false); - return (lines: unsynced, hasWordSync: false, hasTimedLines: false); - } - - /// Parse inline `` timestamps in enhanced LRC word-by-word format. - static List _parseInlineWordTimestamps( - String text, - int lineStartMs, - ) { - // Pattern: or - final pattern = RegExp(r'<(\d{2}):(\d{2})\.(\d{2,3})>'); - final matches = pattern.allMatches(text).toList(); - if (matches.isEmpty) return []; - - final words = []; - - for (var i = 0; i < matches.length; i++) { - final match = matches[i]; - final startMs = _lrcInlineToMs( - match.group(1)!, - match.group(2)!, - match.group(3)!, - ); - - // Text runs from after this timestamp to the next timestamp (or end) - final textStart = match.end; - final textEnd = i + 1 < matches.length - ? matches[i + 1].start - : text.length; - final wordText = text.substring(textStart, textEnd); - - if (wordText.trim().isEmpty) continue; - - // End time is the start of the next word, or line end + buffer - final endMs = i + 1 < matches.length - ? _lrcInlineToMs( - matches[i + 1].group(1)!, - matches[i + 1].group(2)!, - matches[i + 1].group(3)!, - ) - : startMs + 2000; - - words.add(LyricsWord(text: wordText, startMs: startMs, endMs: endMs)); - } - - return words; - } - - static int _lrcInlineToMs(String min, String sec, String cs) { - final m = int.tryParse(min) ?? 0; - final s = int.tryParse(sec) ?? 0; - var c = int.tryParse(cs) ?? 0; - if (cs.length == 2) c *= 10; - return m * 60000 + s * 1000 + c; - } - - /// Remove inline timestamps like for clean display text. - static String _stripInlineTimestamps(String text) { - return text - .replaceAll(RegExp(r'<\d{2}:\d{2}\.\d{2,3}>'), '') - .replaceAll(RegExp(r'\[bg:.*?\]'), '') - .trim(); - } - - void _resetSmartQueueSessionState({required bool clearRecent}) { - _smartQueueRefillInFlight = false; - _lastSmartQueueRefillAt = null; - _smartQueueAutoAddedCount = 0; - _smartQueueSkipStreak = 0; - _smartQueueSessionProfile = const _SmartQueueSessionProfile( - mode: _SmartQueueSessionMode.balanced, - targetDurationSec: 215, - preferredSourceKey: '', - ); - _smartQueuePendingFeedbackByTrack.clear(); - _smartQueueSearchCache.clear(); - _smartQueueRelatedArtistsCache.clear(); - if (clearRecent) { - _recentPlayedTrackKeys.clear(); - _smartQueueSessionSignals.clear(); - _smartQueueTempoHintByTrackKey.clear(); - } - } - - bool _isSmartQueueEnabled() { - final settings = ref.read(settingsProvider); - if (!settings.smartQueueEnabled) return false; - if (state.repeatMode == RepeatMode.all || - state.repeatMode == RepeatMode.one) { - return false; - } - if (state.isLoading || state.currentIndex < 0 || state.queue.isEmpty) { - return false; - } - if (state.currentItem?.track == null) return false; - if (_smartQueueAutoAddedCount >= _smartQueueMaxAutoAddsPerSession) { - return false; - } - return true; - } - - String _normalizeSmartQueueKey(String value) => value.trim().toLowerCase(); - - String _trackKeyFromTrack(Track track) { - final isrc = _normalizeSmartQueueKey(track.isrc ?? ''); - if (isrc.isNotEmpty) return 'isrc:$isrc'; - - final source = _normalizeSmartQueueKey(track.source ?? ''); - final id = _normalizeSmartQueueKey(track.id); - if (source.isNotEmpty && id.isNotEmpty) return 'src:$source:$id'; - if (id.isNotEmpty) return 'id:$id'; - - final title = _normalizeSmartQueueKey(track.name); - final artist = _normalizeSmartQueueKey(track.artistName); - if (title.isNotEmpty || artist.isNotEmpty) { - return 'name:$title|$artist'; - } - return ''; - } - - String _trackKeyFromPlaybackItem(PlaybackItem item) { - final fromTrack = item.track; - if (fromTrack != null) { - final key = _trackKeyFromTrack(fromTrack); - if (key.isNotEmpty) return key; - } - - final id = _normalizeSmartQueueKey(item.id); - if (id.isNotEmpty) return 'id:$id'; - - final title = _normalizeSmartQueueKey(item.title); - final artist = _normalizeSmartQueueKey(item.artist); - if (title.isNotEmpty || artist.isNotEmpty) { - return 'name:$title|$artist'; - } - return ''; - } - - void _rememberRecentPlayed(PlaybackItem item) { - final key = _trackKeyFromPlaybackItem(item); - if (key.isEmpty) return; - _recentPlayedTrackKeys.remove(key); - _recentPlayedTrackKeys.insert(0, key); - if (_recentPlayedTrackKeys.length > _smartQueueRecentPlayedWindow) { - _recentPlayedTrackKeys.removeRange( - _smartQueueRecentPlayedWindow, - _recentPlayedTrackKeys.length, - ); - } - } - - void _learnFromCurrentTrackOutcome({required bool completedNaturally}) { - final current = state.currentItem; - if (current == null) return; - final key = _trackKeyFromPlaybackItem(current); - if (key.isEmpty) return; - - final durationMs = max( - 1, - state.duration.inMilliseconds > 0 - ? state.duration.inMilliseconds - : current.durationMs, - ); - final positionMs = state.position.inMilliseconds.clamp(0, durationMs); - final listenRatio = completedNaturally ? 1.0 : (positionMs / durationMs); - final skipStreakBefore = _smartQueueSkipStreak; - if (current.track != null) { - _recordSmartQueueSessionSignal( - track: current.track!, - listenRatio: listenRatio, - completedNaturally: completedNaturally, - ); - } - _updateSmartQueueSkipStreak( - listenRatio: listenRatio, - completedNaturally: completedNaturally, - ); - - final context = _smartQueuePendingFeedbackByTrack.remove(key); - if (context == null) return; - if (DateTime.now().difference(context.addedAt) > - _smartQueueFeedbackMaxAge) { - return; - } - - final hourBucket = _currentSmartQueueHourBucket(); - final reward = _smartQueueRewardFromListenRatio( - listenRatio: listenRatio, - completedNaturally: completedNaturally, - currentSkipStreak: skipStreakBefore, - hourAffinityRaw: _smartQueueHourAffinity[hourBucket] ?? 0.0, - ); - _updateSmartQueueModel( - features: context.features, - reward: reward, - track: current.track, - hourBucket: hourBucket, - ); - } - - double _smartQueueRewardFromListenRatio({ - required double listenRatio, - required bool completedNaturally, - required int currentSkipStreak, - required double hourAffinityRaw, - }) { - double reward; - if (completedNaturally || listenRatio >= 0.98) { - reward = 1.0; - } else if (listenRatio >= 0.75) { - reward = 0.85; - } else if (listenRatio >= 0.50) { - reward = 0.65; - } else if (listenRatio >= 0.25) { - reward = 0.35; - } else if (listenRatio >= 0.12) { - reward = 0.15; - } else { - reward = 0.0; - } - - // Contextual bandit shaping: adjust reward based on current context. - final hourAffinity = ((hourAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - reward += (hourAffinity - 0.5) * 0.10; - if (!completedNaturally && listenRatio < 0.25 && currentSkipStreak >= 2) { - reward -= 0.08; - } - if (completedNaturally && currentSkipStreak >= 2) { - reward += 0.05; - } - return reward.clamp(0.0, 1.0); - } - - void _updateSmartQueueSkipStreak({ - required double listenRatio, - required bool completedNaturally, - }) { - if (completedNaturally || listenRatio >= 0.70) { - _smartQueueSkipStreak = 0; - return; - } - if (listenRatio < 0.35) { - _smartQueueSkipStreak = min( - _smartQueueMaxSkipStreak, - _smartQueueSkipStreak + 1, - ); - return; - } - _smartQueueSkipStreak = max(0, _smartQueueSkipStreak - 1); - } - - String _currentSmartQueueHourBucket() { - final hour = DateTime.now().hour; - return 'h${hour.toString().padLeft(2, '0')}'; - } - - void _recordSmartQueueSessionSignal({ - required Track track, - required double listenRatio, - required bool completedNaturally, - }) { - _smartQueueSessionSignals.add( - _SmartQueueSessionSignal( - artistKey: _normalizeSmartQueueKey(track.artistName), - sourceKey: _sourceKey(track.source ?? ''), - durationSec: max(1, track.duration), - releaseYear: _parseYear(track.releaseDate), - listenRatio: listenRatio.clamp(0.0, 1.0), - skipped: !completedNaturally && listenRatio < 0.70, - ), - ); - final maxSignals = _smartQueueSessionWindowSize * 6; - if (_smartQueueSessionSignals.length > maxSignals) { - _smartQueueSessionSignals.removeRange( - 0, - _smartQueueSessionSignals.length - maxSignals, - ); - } - } - - void _refreshSmartQueueSessionProfile({required Track seed}) { - final recent = - _smartQueueSessionSignals.length <= _smartQueueSessionWindowSize - ? List<_SmartQueueSessionSignal>.from(_smartQueueSessionSignals) - : _smartQueueSessionSignals.sublist( - _smartQueueSessionSignals.length - _smartQueueSessionWindowSize, - ); - if (recent.isEmpty) { - _smartQueueSessionProfile = _SmartQueueSessionProfile( - mode: _SmartQueueSessionMode.balanced, - targetDurationSec: max(140, seed.duration), - targetYear: _parseYear(seed.releaseDate), - preferredSourceKey: _sourceKey(seed.source ?? ''), - ); - return; - } - - final avgDuration = - recent.map((s) => s.durationSec.toDouble()).reduce((a, b) => a + b) / - recent.length; - final avgListen = - recent.map((s) => s.listenRatio).reduce((a, b) => a + b) / - recent.length; - final skipRate = recent.where((s) => s.skipped).length / recent.length; - final variance = - recent - .map((s) => pow((s.durationSec - avgDuration).toDouble(), 2)) - .reduce((a, b) => a + b) / - recent.length; - final durationStdDev = sqrt(variance); - - _SmartQueueSessionMode mode = _SmartQueueSessionMode.balanced; - if (skipRate > 0.45 || avgDuration < 190) { - mode = _SmartQueueSessionMode.energetic; - } else if (avgDuration > 280 && skipRate < 0.28) { - mode = _SmartQueueSessionMode.chill; - } else if (durationStdDev < 45 && avgListen >= 0.58) { - mode = _SmartQueueSessionMode.focus; - } - - final years = - recent - .map((s) => s.releaseYear) - .whereType() - .toList(growable: false) - ..sort(); - final targetYear = years.isEmpty - ? _parseYear(seed.releaseDate) - : years[years.length ~/ 2]; - final sourceCounts = {}; - for (final signal in recent) { - if (signal.sourceKey.isEmpty) continue; - sourceCounts[signal.sourceKey] = - (sourceCounts[signal.sourceKey] ?? 0) + 1; - } - var preferredSourceKey = _sourceKey(seed.source ?? ''); - if (sourceCounts.isNotEmpty) { - preferredSourceKey = - (sourceCounts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value))) - .first - .key; - } - final targetDurationSec = switch (mode) { - _SmartQueueSessionMode.chill => max(240, avgDuration.round()), - _SmartQueueSessionMode.focus => avgDuration.round().clamp(170, 320), - _SmartQueueSessionMode.energetic => avgDuration.round().clamp(120, 220), - _SmartQueueSessionMode.balanced => avgDuration.round().clamp(145, 280), - }; - - _smartQueueSessionProfile = _SmartQueueSessionProfile( - mode: mode, - targetDurationSec: targetDurationSec, - targetYear: targetYear, - preferredSourceKey: preferredSourceKey, - ); - } - - void _updateAffinity(Map map, String key, double reward) { - final normalizedKey = _normalizeSmartQueueKey(key); - if (normalizedKey.isEmpty) return; - - final current = map[normalizedKey] ?? 0.0; - final target = (reward * 2.0) - 1.0; // [0,1] -> [-1,1] - final updated = (current * 0.85) + (target * 0.15); - map[normalizedKey] = updated.clamp(-1.0, 1.0); - - while (map.length > _smartQueueMaxAffinityKeys) { - map.remove(map.keys.first); - } - } - - void _updateSmartQueueModel({ - required Map features, - required double reward, - Track? track, - required String hourBucket, - }) { - final clippedReward = reward.clamp(0.0, 1.0); - final prediction = _smartQueuePredict(features); - final error = clippedReward - prediction; - - final nextBias = - (_smartQueueWeights['bias'] ?? 0.0) + (_smartQueueLearningRate * error); - _smartQueueWeights['bias'] = nextBias.clamp(-3.0, 3.0); - - for (final entry in features.entries) { - final currentWeight = _smartQueueWeights[entry.key] ?? 0.0; - final updatedWeight = - currentWeight + (_smartQueueLearningRate * error * entry.value); - _smartQueueWeights[entry.key] = updatedWeight.clamp(-3.0, 3.0); - } - - if (track != null) { - _updateAffinity( - _smartQueueArtistAffinity, - track.artistName, - clippedReward, - ); - _updateAffinity( - _smartQueueSourceAffinity, - _sourceKey(track.source ?? ''), - clippedReward, - ); - _updateAffinity(_smartQueueHourAffinity, hourBucket, clippedReward); - } - - _scheduleSmartQueueModelSave(); - } - - double _smartQueuePredict(Map features) { - var logit = _smartQueueWeights['bias'] ?? 0.0; - for (final entry in features.entries) { - logit += (_smartQueueWeights[entry.key] ?? 0.0) * entry.value; - } - return _sigmoid(logit); - } - - double _sigmoid(double x) => 1.0 / (1.0 + exp(-x)); - - void _maybeTriggerSmartQueueRefill(Duration position) { - if (!_isSmartQueueEnabled()) return; - if (_smartQueueRefillInFlight) return; - - final remaining = state.queue.length - state.currentIndex - 1; - if (remaining > _smartQueueTriggerRemainingTracks) return; - if (position < const Duration(seconds: 8)) return; - - final lastRefill = _lastSmartQueueRefillAt; - if (lastRefill != null && - DateTime.now().difference(lastRefill) < _smartQueueRefillCooldown) { - return; - } - - unawaited(_autoRefillSmartQueue(force: false)); - } - - Future _autoRefillSmartQueue({required bool force}) async { - if (!_isSmartQueueEnabled()) return 0; - if (_smartQueueRefillInFlight) return 0; - - final remaining = max(0, state.queue.length - state.currentIndex - 1); - final needed = _smartQueueTargetRemainingTracks - remaining; - if (!force && needed <= 0) return 0; - - final lastRefill = _lastSmartQueueRefillAt; - if (!force && - lastRefill != null && - DateTime.now().difference(lastRefill) < _smartQueueRefillCooldown) { - return 0; - } - - final seed = state.currentItem?.track; - if (seed == null) return 0; - _refreshSmartQueueSessionProfile(seed: seed); - - final epoch = _playRequestEpoch; - _smartQueueRefillInFlight = true; - try { - _pruneSmartQueueCaches(); - - final candidates = await _fetchSmartQueueCandidates( - seed, - limit: _smartQueueCandidatePoolLimit, - ); - if (_playRequestEpoch != epoch) return 0; - if (candidates.isEmpty) return 0; - - final existingTrackKeys = {}; - for (final item in state.queue) { - final key = _trackKeyFromPlaybackItem(item); - if (key.isNotEmpty) existingTrackKeys.add(key); - } - existingTrackKeys.addAll(_recentPlayedTrackKeys); - - final scored = <_SmartQueueCandidate>[]; - for (final candidate in candidates) { - final candidateEntry = _buildSmartQueueCandidate( - seed: seed, - candidate: candidate, - existingTrackKeys: existingTrackKeys, - ); - if (candidateEntry == null) continue; - scored.add(candidateEntry); - } - if (scored.isEmpty) return 0; - - scored.sort((a, b) => b.score.compareTo(a.score)); - final targetCount = force ? max(1, needed) : max(0, needed); - if (targetCount <= 0) return 0; - final selected = _selectSmartQueueCandidates( - seed: seed, - sessionProfile: _smartQueueSessionProfile, - scored: scored, - targetCount: targetCount, - ); - if (selected.isEmpty) return 0; - if (_playRequestEpoch != epoch) return 0; - - final queueBefore = state.queue.length; - final updatedQueue = [...state.queue]; - for (final selection in selected) { - final item = _buildQueueItemFromTrack(selection.track); - updatedQueue.add(item); - final itemKey = _trackKeyFromPlaybackItem(item); - if (itemKey.isNotEmpty) { - _smartQueuePendingFeedbackByTrack[itemKey] = - _SmartQueueLearningContext( - features: selection.features, - addedAt: DateTime.now(), - ); - } - } - - state = state.copyWith(queue: updatedQueue); - if (state.shuffle) { - for (var idx = queueBefore; idx < updatedQueue.length; idx++) { - _shuffleOrder.add(idx); - } - } - - _smartQueueAutoAddedCount += selected.length; - _lastSmartQueueRefillAt = DateTime.now(); - unawaited(_savePlaybackSnapshot()); - final sourceSummary = {}; - for (final selection in selected) { - final source = _resolveSmartQueueSourceLabel(selection.track); - sourceSummary[source] = (sourceSummary[source] ?? 0) + 1; - } - final summaryText = sourceSummary.entries - .map((entry) => '${entry.key}:${entry.value}') - .join(', '); - _log.d( - 'Smart queue appended ${selected.length} tracks (remaining=$remaining, session=${_smartQueueSessionProfile.mode.name}, sources=[$summaryText])', - ); - return selected.length; - } catch (e) { - _log.d('Smart queue refill skipped: $e'); - return 0; - } finally { - _smartQueueRefillInFlight = false; - } - } - - Future> _fetchSmartQueueCandidates( - Track seed, { - required int limit, - }) async { - final queries = { - '${seed.artistName} ${seed.name}'.trim(), - seed.artistName.trim(), - '${seed.artistName} ${seed.albumName}'.trim(), - }.where((q) => q.isNotEmpty).take(3).toList(growable: false); - - if (queries.isEmpty) return const []; - - final perQueryLimit = max(10, (limit / queries.length).ceil() + 4); - final results = await Future.wait( - queries.map( - (q) => _searchTracksForSmartQueue(q, trackLimit: perQueryLimit), - ), - ); - - final merged = []; - for (final list in results) { - merged.addAll(list); - if (merged.length >= limit * 2) break; - } - - final relatedArtistTracks = await _fetchRelatedArtistTracksForSmartQueue( - seed, - fallbackTracks: merged, - limit: limit, - ); - if (relatedArtistTracks.isNotEmpty) { - merged.addAll(relatedArtistTracks); - } - - if (merged.isEmpty) { - merged.addAll( - _fallbackOfflineTracksForSmartQueue(seed: seed, limit: max(12, limit)), - ); - } - - return merged; - } - - List _fallbackOfflineTracksForSmartQueue({ - required Track seed, - required int limit, - }) { - if (limit <= 0) return const []; - - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final seedKey = _trackKeyFromTrack(seed); - final seedArtist = _normalizeSmartQueueKey(seed.artistName); - final seedAlbum = _normalizeSmartQueueKey(seed.albumName); - final seedSource = _sourceKey(seed.source ?? ''); - - final scored = <_OfflineSmartQueueTrackHit>[]; - for (final track in pool) { - final key = _trackKeyFromTrack(track); - if (key.isEmpty || key == seedKey) continue; - - var score = 0.35; - final artistKey = _normalizeSmartQueueKey(track.artistName); - final albumKey = _normalizeSmartQueueKey(track.albumName); - final sourceKey = _sourceKey(track.source ?? ''); - if (artistKey.isNotEmpty && artistKey == seedArtist) { - score += 2.1; - } - if (albumKey.isNotEmpty && albumKey == seedAlbum) { - score += 1.25; - } - if (sourceKey == seedSource) { - score += 0.35; - } - score += _durationSimilarity(seed.duration, track.duration) * 0.55; - score += - _releaseYearSimilarity(seed.releaseDate, track.releaseDate) * 0.3; - score += _smartQueueRandom.nextDouble() * 0.08; - scored.add(_OfflineSmartQueueTrackHit(track: track, score: score)); - } - - scored.sort((a, b) => b.score.compareTo(a.score)); - return scored - .take(limit) - .map((entry) => entry.track) - .toList(growable: false); - } - - Future> _fetchRelatedArtistTracksForSmartQueue( - Track seed, { - required List fallbackTracks, - required int limit, - }) async { - final seedArtist = _normalizeSmartQueueKey(seed.artistName); - if (seedArtist.isEmpty) return const []; - - final relatedArtists = await _discoverRelatedArtistsForSmartQueue( - seed, - fallbackTracks: fallbackTracks, - limit: _smartQueueRelatedArtistsLimit, - ); - if (relatedArtists.isEmpty) return const []; - - final perArtistLimit = max( - 6, - (limit / max(1, relatedArtists.length)).ceil(), - ); - final results = await Future.wait( - relatedArtists.map( - (artist) => - _searchTracksForSmartQueue(artist.name, trackLimit: perArtistLimit), - ), - ); - - final merged = []; - for (final tracks in results) { - for (final track in tracks) { - final artist = _normalizeSmartQueueKey(track.artistName); - if (artist.isEmpty || artist == seedArtist) continue; - merged.add(track); - } - if (merged.length >= limit) break; - } - return merged; - } - - Future> _discoverRelatedArtistsForSmartQueue( - Track seed, { - required List fallbackTracks, - required int limit, - }) async { - final seedArtist = _normalizeSmartQueueKey(seed.artistName); - if (seedArtist.isEmpty || limit <= 0) return const []; - - final cacheKey = 'seed:$seedArtist'; - final cached = _smartQueueRelatedArtistsCache[cacheKey]; - final now = DateTime.now(); - if (cached != null && - now.difference(cached.fetchedAt) < _smartQueueSearchCacheTtl) { - return cached.artists.take(limit).toList(growable: false); - } - - final relatedByName = {}; - void addCandidate(_SmartQueueRelatedArtist candidate) { - final key = _normalizeSmartQueueKey(candidate.name); - if (key.isEmpty || key == seedArtist) return; - final existing = relatedByName[key]; - if (existing == null || candidate.score > existing.score) { - relatedByName[key] = candidate; - } - } - - final spotifySeed = await _findArtistSeedBySearch( - queryArtistName: seed.artistName, - provider: 'spotify', - ); - if (spotifySeed != null) { - final related = await _fetchRelatedArtistsFromProviderSeed(spotifySeed); - for (final item in related) { - addCandidate(item); - } - } - - final deezerSeed = await _findArtistSeedBySearch( - queryArtistName: seed.artistName, - provider: 'deezer', - ); - if (deezerSeed != null) { - final related = await _fetchRelatedArtistsFromProviderSeed(deezerSeed); - for (final item in related) { - addCandidate(item); - } - } - - // Fallback heuristic from current track candidates if provider APIs don't return enough. - if (relatedByName.length < limit) { - final counts = {}; - for (final track in fallbackTracks.take(80)) { - final artistName = track.artistName.trim(); - final key = _normalizeSmartQueueKey(artistName); - if (key.isEmpty || key == seedArtist) continue; - counts[key] = (counts[key] ?? 0) + 1; - } - for (final entry in counts.entries) { - addCandidate( - _SmartQueueRelatedArtist( - name: entry.key, - provider: 'fallback', - score: min(1.0, 0.25 + (entry.value * 0.14)), - ), - ); - } - } - - final sorted = relatedByName.values.toList() - ..sort((a, b) => b.score.compareTo(a.score)); - _smartQueueRelatedArtistsCache[cacheKey] = _SmartQueueRelatedArtistsCache( - artists: sorted, - fetchedAt: now, - ); - return sorted.take(limit).toList(growable: false); - } - - Future<_SmartQueueArtistSeed?> _findArtistSeedBySearch({ - required String queryArtistName, - required String provider, - }) async { - final normalizedProvider = provider.trim().toLowerCase(); - final query = queryArtistName.trim(); - if (query.isEmpty) return null; - - final artists = await _searchArtistsForSmartQueue( - query: query, - provider: normalizedProvider, - limit: 8, - ); - if (artists.isEmpty) return null; - - artists.sort((a, b) => b.score.compareTo(a.score)); - return artists.first; - } - - Future> _fetchRelatedArtistsFromProviderSeed( - _SmartQueueArtistSeed seed, - ) async { - if (seed.provider == 'spotify') { - return _fetchSpotifyRelatedArtistsForSmartQueue(seed); - } - return _buildOfflineRelatedArtistsFromSeed( - seed, - providerLabel: seed.provider, - ); - } - - Future> - _fetchSpotifyRelatedArtistsForSmartQueue(_SmartQueueArtistSeed seed) async { - return _buildOfflineRelatedArtistsFromSeed( - seed, - providerLabel: _smartQueueSpotifyExtensionId, - ); - } - - Future> _buildOfflineRelatedArtistsFromSeed( - _SmartQueueArtistSeed seed, { - required String providerLabel, - }) async { - final seedArtistKey = _normalizeSmartQueueKey(seed.name); - if (seedArtistKey.isEmpty) return const []; - - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final candidateScoreByKey = {}; - final candidateNameByKey = {}; - final seedAlbumKeys = {}; - - void addCandidate(String rawName, double score) { - final name = rawName.trim(); - final key = _normalizeSmartQueueKey(name); - if (key.isEmpty || key == seedArtistKey || score <= 0) return; - candidateNameByKey[key] = name; - candidateScoreByKey[key] = (candidateScoreByKey[key] ?? 0) + score; - } - - for (final track in pool) { - final artists = _extractArtistNamesForSmartQueue(track.artistName); - if (artists.isEmpty) continue; - - final containsSeed = artists.any( - (artistName) => _normalizeSmartQueueKey(artistName) == seedArtistKey, - ); - if (containsSeed) { - for (final artistName in artists) { - final key = _normalizeSmartQueueKey(artistName); - if (key == seedArtistKey) continue; - addCandidate(artistName, 0.85); - } - final albumKey = _normalizeSmartQueueKey(track.albumName); - if (albumKey.isNotEmpty) { - seedAlbumKeys.add(albumKey); - } - } - } - - if (seedAlbumKeys.isNotEmpty) { - for (final track in pool) { - final albumKey = _normalizeSmartQueueKey(track.albumName); - if (albumKey.isEmpty || !seedAlbumKeys.contains(albumKey)) continue; - for (final artistName in _extractArtistNamesForSmartQueue( - track.artistName, - )) { - final key = _normalizeSmartQueueKey(artistName); - if (key == seedArtistKey) continue; - addCandidate(artistName, 0.38); - } - } - } - - if (candidateScoreByKey.isEmpty) return const []; - - final related = <_SmartQueueRelatedArtist>[]; - for (final entry in candidateScoreByKey.entries) { - related.add( - _SmartQueueRelatedArtist( - name: candidateNameByKey[entry.key] ?? entry.key, - provider: providerLabel, - score: entry.value.clamp(0.05, 1.0), - ), - ); - } - related.sort((a, b) => b.score.compareTo(a.score)); - return related.take(10).toList(growable: false); - } - - List _extractArtistNamesForSmartQueue(String rawArtists) { - final tokens = splitArtistNames(rawArtists); - if (tokens.isEmpty) return const []; - - final names = []; - final seen = {}; - for (final token in tokens) { - final name = token.trim(); - if (name.isEmpty) continue; - final key = _normalizeSmartQueueKey(name); - if (key.isEmpty || !seen.add(key)) continue; - names.add(name); - } - return names; - } - - Future> _searchArtistsForSmartQueue({ - required String query, - required String provider, - int limit = 8, - }) async { - final normalizedQuery = query.trim(); - if (normalizedQuery.isEmpty) return const []; - - final normalizedProvider = provider.trim().toLowerCase(); - if (normalizedProvider != 'spotify' && normalizedProvider != 'deezer') { - return const []; - } - - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final statsByArtist = {}; - for (final track in pool) { - for (final artistName in _extractArtistNamesForSmartQueue( - track.artistName, - )) { - final key = _normalizeSmartQueueKey(artistName); - if (key.isEmpty) continue; - final similarity = _artistNameSimilarity(normalizedQuery, artistName); - if (similarity <= 0.05 && - !key.contains(_normalizeSmartQueueKey(normalizedQuery))) { - continue; - } - - final current = statsByArtist[key]; - if (current == null) { - statsByArtist[key] = _OfflineSmartQueueArtistStats( - name: artistName, - count: 1, - scoreSum: similarity, - ); - } else { - statsByArtist[key] = _OfflineSmartQueueArtistStats( - name: current.name, - count: current.count + 1, - scoreSum: current.scoreSum + similarity, - ); - } - } - } - - if (statsByArtist.isEmpty) return const []; - final ranked = <_SmartQueueArtistSeed>[]; - for (final entry in statsByArtist.entries) { - final stats = entry.value; - final frequencyBoost = min(1.0, stats.count / 18.0); - final meanSimilarity = stats.scoreSum / max(1, stats.count); - final score = ((meanSimilarity * 0.78) + (frequencyBoost * 0.22)).clamp( - 0.0, - 1.0, - ); - ranked.add( - _SmartQueueArtistSeed( - id: '$normalizedProvider:${entry.key}', - name: stats.name, - provider: normalizedProvider, - score: score, - ), - ); - } - - ranked.sort((a, b) => b.score.compareTo(a.score)); - return ranked.take(max(1, limit)).toList(growable: false); - } - - double _artistNameSimilarity(String a, String b) { - final na = _normalizeSmartQueueKey(a); - final nb = _normalizeSmartQueueKey(b); - if (na.isEmpty || nb.isEmpty) return 0.0; - if (na == nb) return 1.0; - if (na.contains(nb) || nb.contains(na)) return 0.88; - - final tokensA = na - .split(RegExp(r'[^a-z0-9]+')) - .where((t) => t.isNotEmpty) - .toSet(); - final tokensB = nb - .split(RegExp(r'[^a-z0-9]+')) - .where((t) => t.isNotEmpty) - .toSet(); - if (tokensA.isEmpty || tokensB.isEmpty) return 0.0; - - final intersection = tokensA.intersection(tokensB).length; - final union = tokensA.union(tokensB).length; - if (union == 0) return 0.0; - return intersection / union; - } - - Future> _searchTracksForSmartQueue( - String query, { - int trackLimit = 20, - }) async { - final normalizedQuery = _normalizeSmartQueueKey(query); - if (normalizedQuery.isEmpty) return const []; - - final now = DateTime.now(); - final cached = _smartQueueSearchCache[normalizedQuery]; - if (cached != null && - now.difference(cached.fetchedAt) < _smartQueueSearchCacheTtl) { - return cached.tracks; - } - - final settings = ref.read(settingsProvider); - final preferSpotify = - settings.metadataSource.trim().toLowerCase() == 'spotify'; - final primaryLimit = max( - trackLimit, - (trackLimit * _smartQueuePrimarySourceRatio).round() + 5, - ); - final secondaryLimit = max(trackLimit ~/ 2, trackLimit - 2); - - final primaryResults = await (preferSpotify - ? _safeSmartQueueTrackSearch( - () => _searchSpotifyTracksForSmartQueue( - normalizedQuery, - trackLimit: primaryLimit, - ), - ) - : _safeSmartQueueTrackSearch( - () => _searchDeezerTracksForSmartQueue( - normalizedQuery, - trackLimit: primaryLimit, - ), - )); - final shouldQuerySecondary = - primaryResults.length < - max(8, (trackLimit * _smartQueuePrimarySourceRatio).round()); - final secondaryResults = shouldQuerySecondary - ? (preferSpotify - ? await _safeSmartQueueTrackSearch( - () => _searchDeezerTracksForSmartQueue( - normalizedQuery, - trackLimit: secondaryLimit, - ), - ) - : await _safeSmartQueueTrackSearch( - () => _searchSpotifyTracksForSmartQueue( - normalizedQuery, - trackLimit: secondaryLimit, - ), - )) - : const >[]; - - final blended = _blendSmartQueueTrackCandidates( - primary: primaryResults, - secondary: secondaryResults, - targetCount: max(10, trackLimit + 6), - primaryRatio: _smartQueuePrimarySourceRatio, - ); - - final parsedTracks = []; - final seenTrackKeys = {}; - for (final entry in blended) { - final track = _parseSearchTrackForSmartQueue(entry); - if (track.id.trim().isEmpty || track.name.trim().isEmpty) continue; - if (track.isCollection) continue; - final key = _trackKeyFromTrack(track); - if (key.isNotEmpty && !seenTrackKeys.add(key)) continue; - _registerSmartQueueTrackHints(track: track, raw: entry); - parsedTracks.add(track); - } - - _smartQueueSearchCache[normalizedQuery] = _SmartQueueCachedResult( - tracks: parsedTracks, - fetchedAt: now, - ); - return parsedTracks; - } - - Future>> _safeSmartQueueTrackSearch( - Future>> Function() resolver, - ) async { - try { - return await resolver(); - } catch (e) { - _log.d('Smart queue source search failed: $e'); - return const >[]; - } - } - - List> _blendSmartQueueTrackCandidates({ - required List> primary, - required List> secondary, - required int targetCount, - required double primaryRatio, - }) { - final merged = >[]; - final seen = {}; - var primaryIndex = 0; - var secondaryIndex = 0; - var primaryTaken = 0; - var secondaryTaken = 0; - final maxTarget = max(1, targetCount); - - void tryTakeFrom(List> source, bool isPrimary) { - while (true) { - final index = isPrimary ? primaryIndex : secondaryIndex; - if (index >= source.length) return; - final item = source[index]; - if (isPrimary) { - primaryIndex++; - } else { - secondaryIndex++; - } - final dedupKey = _smartQueueRawTrackDedupKey(item); - if (dedupKey.isEmpty || !seen.add(dedupKey)) { - continue; - } - merged.add(item); - if (isPrimary) { - primaryTaken++; - } else { - secondaryTaken++; - } - return; - } - } - - while (merged.length < maxTarget && - (primaryIndex < primary.length || secondaryIndex < secondary.length)) { - final expectedPrimary = ((merged.length + 1) * primaryRatio).round(); - final shouldTakePrimary = - secondaryIndex >= secondary.length || - (primaryIndex < primary.length && primaryTaken < expectedPrimary); - if (shouldTakePrimary) { - tryTakeFrom(primary, true); - } else { - tryTakeFrom(secondary, false); - } - if (merged.length >= maxTarget) break; - if (primaryIndex >= primary.length && secondaryIndex < secondary.length) { - tryTakeFrom(secondary, false); - } else if (secondaryIndex >= secondary.length && - primaryIndex < primary.length) { - tryTakeFrom(primary, true); - } - if (primaryTaken + secondaryTaken == 0) { - break; - } - } - return merged; - } - - String _smartQueueRawTrackDedupKey(Map raw) { - final id = (raw['spotify_id'] ?? raw['id'] ?? '').toString().trim(); - final source = (raw['source'] ?? raw['provider_id'] ?? '') - .toString() - .trim(); - if (id.isNotEmpty && source.isNotEmpty) { - return 'src:${_normalizeSmartQueueKey(source)}:${_normalizeSmartQueueKey(id)}'; - } - if (id.isNotEmpty) { - return 'id:${_normalizeSmartQueueKey(id)}'; - } - final title = (raw['name'] ?? '').toString().trim(); - final artist = (raw['artists'] ?? raw['artist'] ?? '').toString().trim(); - if (title.isEmpty && artist.isEmpty) return ''; - return 'name:${_normalizeSmartQueueKey(title)}|${_normalizeSmartQueueKey(artist)}'; - } - - Future>> _searchSpotifyTracksForSmartQueue( - String query, { - required int trackLimit, - }) async { - return _searchOfflineTracksForSmartQueue( - query, - trackLimit: trackLimit, - providerHint: 'spotify', - ); - } - - Future>> _searchDeezerTracksForSmartQueue( - String query, { - required int trackLimit, - }) async { - return _searchOfflineTracksForSmartQueue( - query, - trackLimit: trackLimit, - providerHint: 'deezer', - ); - } - - Future>> _searchOfflineTracksForSmartQueue( - String query, { - required int trackLimit, - required String providerHint, - }) async { - final normalizedQuery = _normalizeSmartQueueKey(query); - if (normalizedQuery.isEmpty || trackLimit <= 0) return const []; - - final terms = normalizedQuery - .split(RegExp(r'[^a-z0-9]+')) - .where((token) => token.isNotEmpty) - .toList(growable: false); - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final scored = <_OfflineSmartQueueTrackHit>[]; - for (final track in pool) { - var score = _searchScoreForOfflineTrack( - track, - normalizedQuery: normalizedQuery, - terms: terms, - ); - if (score <= 0) continue; - - if (providerHint == 'spotify' && _looksLikeSpotifyTrackId(track.id)) { - score += 0.22; - } else if (providerHint == 'deezer' && - _looksLikeDeezerTrackId(track.id, track.deezerId)) { - score += 0.22; - } - - final artistAffinity = - _smartQueueArtistAffinity[_normalizeSmartQueueKey( - track.artistName, - )] ?? - 0.0; - score += max(0.0, artistAffinity) * 0.25; - score += _smartQueueRandom.nextDouble() * 0.05; - scored.add(_OfflineSmartQueueTrackHit(track: track, score: score)); - } - - if (scored.isEmpty) return const []; - scored.sort((a, b) => b.score.compareTo(a.score)); - final target = max(1, trackLimit); - return scored - .take(target) - .map( - (entry) => _rawMapForOfflineSmartQueueTrack( - entry.track, - providerHint: providerHint, - ), - ) - .toList(growable: false); - } - - List _buildOfflineTrackPoolForSmartQueue({required int maxItems}) { - if (maxItems <= 0) return const []; - - final localItems = [...ref.read(localLibraryProvider).items]; - final historyItems = [...ref.read(downloadHistoryProvider).items]; - localItems.sort((a, b) => b.scannedAt.compareTo(a.scannedAt)); - historyItems.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); - - final pool = []; - final seen = {}; - final perSourceCap = max(40, maxItems ~/ 2); - - void addTrack(Track? track) { - if (track == null) return; - final name = track.name.trim(); - final artist = track.artistName.trim(); - if (name.isEmpty || artist.isEmpty) return; - final key = _trackKeyFromTrack(track); - if (key.isEmpty || !seen.add(key)) return; - pool.add(track); - } - - for (final item in historyItems.take(perSourceCap)) { - addTrack(_trackFromDownloadHistoryForSmartQueue(item)); - } - for (final item in localItems.take(perSourceCap)) { - addTrack(_trackFromLocalLibraryForSmartQueue(item)); - } - - if (pool.length <= maxItems) return pool; - return pool.take(maxItems).toList(growable: false); - } - - Track? _trackFromDownloadHistoryForSmartQueue(DownloadHistoryItem item) { - final path = item.filePath.trim(); - if (path.isEmpty) return null; - final title = item.trackName.trim(); - final artist = item.artistName.trim(); - if (title.isEmpty || artist.isEmpty) return null; - - final spotifyId = (item.spotifyId ?? '').trim(); - final id = spotifyId.isNotEmpty ? spotifyId : 'history:${item.id}'; - return Track( - id: id, - name: title, - artistName: artist, - albumName: item.albumName, - albumArtist: item.albumArtist, - coverUrl: item.coverUrl, - isrc: item.isrc, - duration: max(0, item.duration ?? 0), - trackNumber: item.trackNumber, - discNumber: item.discNumber, - releaseDate: item.releaseDate, - source: 'offline', - ); - } - - Track? _trackFromLocalLibraryForSmartQueue(LocalLibraryItem item) { - final path = item.filePath.trim(); - if (path.isEmpty) return null; - - final title = item.trackName.trim(); - final artist = item.artistName.trim(); - if (title.isEmpty || artist.isEmpty) return null; - - return Track( - id: 'local:${item.id}', - name: title, - artistName: artist, - albumName: item.albumName, - albumArtist: item.albumArtist, - coverUrl: item.coverPath, - isrc: item.isrc, - duration: max(0, item.duration ?? 0), - trackNumber: item.trackNumber, - discNumber: item.discNumber, - releaseDate: item.releaseDate, - source: 'local', - ); - } - - double _searchScoreForOfflineTrack( - Track track, { - required String normalizedQuery, - required List terms, - }) { - final title = _normalizeSmartQueueKey(track.name); - final artist = _normalizeSmartQueueKey(track.artistName); - final album = _normalizeSmartQueueKey(track.albumName); - final full = '$title $artist $album'; - if (full.trim().isEmpty) return 0; - - var score = 0.0; - if (title == normalizedQuery) { - score += 4.2; - } else if (title.startsWith(normalizedQuery)) { - score += 3.5; - } else if (title.contains(normalizedQuery)) { - score += 2.8; - } - - if (artist == normalizedQuery) { - score += 3.4; - } else if (artist.startsWith(normalizedQuery)) { - score += 2.7; - } else if (artist.contains(normalizedQuery)) { - score += 2.0; - } - - if (album == normalizedQuery) { - score += 1.6; - } else if (album.contains(normalizedQuery) && album.isNotEmpty) { - score += 1.0; - } - - var matchedTerms = 0; - for (final term in terms) { - if (term.length < 2) continue; - if (title.contains(term)) { - score += 0.85; - matchedTerms++; - } else if (artist.contains(term)) { - score += 0.72; - matchedTerms++; - } else if (album.contains(term)) { - score += 0.48; - matchedTerms++; - } - } - if (terms.isNotEmpty && matchedTerms == terms.length) { - score += 0.6; - } - return score; - } - - bool _looksLikeSpotifyTrackId(String rawId) { - final id = rawId.trim(); - if (id.isEmpty) return false; - final lowered = id.toLowerCase(); - if (lowered.startsWith('spotify:track:')) return true; - if (lowered.contains('open.spotify.com/track/')) return true; - return RegExp(r'^[a-z0-9]{22}$', caseSensitive: false).hasMatch(id); - } - - bool _looksLikeDeezerTrackId(String rawId, String? deezerId) { - if (deezerId != null && deezerId.trim().isNotEmpty) return true; - final id = rawId.trim(); - if (id.isEmpty) return false; - return RegExp(r'^\d{4,}$').hasMatch(id); - } - - Map _rawMapForOfflineSmartQueueTrack( - Track track, { - required String providerHint, - }) { - final rawId = track.id.trim(); - final map = { - 'id': rawId, - 'name': track.name, - 'artists': track.artistName, - 'artist': track.artistName, - 'album_name': track.albumName, - 'album': track.albumName, - 'album_artist': track.albumArtist, - 'cover_url': track.coverUrl, - 'isrc': track.isrc, - 'duration': track.duration, - 'duration_ms': track.duration > 0 ? track.duration * 1000 : 0, - 'track_number': track.trackNumber, - 'disc_number': track.discNumber, - 'release_date': track.releaseDate, - 'album_type': track.albumType, - 'item_type': 'track', - 'source': track.source ?? 'offline', - 'provider_id': providerHint, - 'deezer_id': track.deezerId, - }; - - if (_looksLikeSpotifyTrackId(rawId)) { - final spotifyId = rawId.toLowerCase().startsWith('spotify:track:') - ? rawId.split(':').last - : rawId; - map['spotify_id'] = spotifyId; - } - return map; - } - - String _resolveSmartQueueSourceLabel(Track track) { - final raw = (track.source ?? '').trim().toLowerCase(); - if (raw.isNotEmpty) return raw; - final id = track.id.trim().toLowerCase(); - if (id.startsWith('deezer:')) return 'deezer'; - if (id.startsWith('spotify:')) return 'spotify'; - return 'unknown'; - } - - Track _parseSearchTrackForSmartQueue( - Map data, { - String? source, - }) { - final durationMs = _extractDurationMsForSmartQueue(data); - final itemType = data['item_type']?.toString(); - 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(), - artistId: (data['artist_id'] ?? data['artistId'])?.toString(), - albumId: data['album_id']?.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: - source ?? - data['source']?.toString() ?? - data['provider_id']?.toString(), - albumType: data['album_type']?.toString(), - itemType: itemType, - deezerId: data['deezer_id']?.toString(), - ); - } - - int _extractDurationMsForSmartQueue(Map data) { - final durationMsRaw = data['duration_ms']; - if (durationMsRaw is num && durationMsRaw > 0) { - return durationMsRaw.toInt(); - } - if (durationMsRaw is String) { - final parsed = num.tryParse(durationMsRaw.trim()); - if (parsed != null && parsed > 0) { - return parsed.toInt(); - } - } - - final durationSecRaw = data['duration']; - if (durationSecRaw is num && durationSecRaw > 0) { - return (durationSecRaw * 1000).toInt(); - } - if (durationSecRaw is String) { - final parsed = num.tryParse(durationSecRaw.trim()); - if (parsed != null && parsed > 0) { - return (parsed * 1000).toInt(); - } - } - return 0; - } - - void _registerSmartQueueTrackHints({ - required Track track, - required Map raw, - }) { - final tempo = _extractTempoBpmForSmartQueue(raw); - if (tempo == null || tempo <= 0) return; - final key = _trackKeyFromTrack(track); - if (key.isEmpty) return; - _smartQueueTempoHintByTrackKey[key] = tempo; - if (_smartQueueTempoHintByTrackKey.length > _smartQueueMaxTempoHints) { - final removeCount = - _smartQueueTempoHintByTrackKey.length - _smartQueueMaxTempoHints; - final keys = _smartQueueTempoHintByTrackKey.keys - .take(removeCount) - .toList(growable: false); - for (final k in keys) { - _smartQueueTempoHintByTrackKey.remove(k); - } - } - } - - double? _extractTempoBpmForSmartQueue(Map raw) { - const keys = ['tempo', 'bpm', 'audio_tempo', 'track_bpm']; - for (final key in keys) { - final value = raw[key]; - if (value is num) { - final bpm = value.toDouble(); - if (bpm > 30 && bpm < 260) return bpm; - } else if (value is String) { - final bpm = double.tryParse(value.trim()); - if (bpm != null && bpm > 30 && bpm < 260) return bpm; - } - } - return null; - } - - _SmartQueueCandidate? _buildSmartQueueCandidate({ - required Track seed, - required Track candidate, - required Set existingTrackKeys, - }) { - final candidateKey = _trackKeyFromTrack(candidate); - if (candidateKey.isEmpty || existingTrackKeys.contains(candidateKey)) { - return null; - } - - final features = _buildSmartQueueFeatures( - seed: seed, - candidate: candidate, - existingTrackKeys: existingTrackKeys, - ); - final prediction = _smartQueuePredict(features); - final exploration = - _smartQueueRandom.nextDouble() * _sessionExplorationCeiling(); - final score = prediction + exploration; - return _SmartQueueCandidate( - track: candidate, - key: candidateKey, - features: features, - score: score, - ); - } - - Map _buildSmartQueueFeatures({ - required Track seed, - required Track candidate, - required Set existingTrackKeys, - }) { - final sameArtist = - _normalizeSmartQueueKey(seed.artistName) == - _normalizeSmartQueueKey(candidate.artistName) - ? 1.0 - : 0.0; - final sameAlbum = - _normalizeSmartQueueKey(seed.albumName) == - _normalizeSmartQueueKey(candidate.albumName) - ? 1.0 - : 0.0; - final durationSimilarity = _durationSimilarity( - seed.duration, - candidate.duration, - ); - final sourceMatch = - _sourceKey(seed.source ?? '') == _sourceKey(candidate.source ?? '') - ? 1.0 - : 0.0; - final releaseYearSimilarity = _releaseYearSimilarity( - seed.releaseDate, - candidate.releaseDate, - ); - final artistAffinityRaw = - _smartQueueArtistAffinity[_normalizeSmartQueueKey( - candidate.artistName, - )] ?? - 0.0; - final sourceAffinityRaw = - _smartQueueSourceAffinity[_sourceKey(candidate.source ?? '')] ?? 0.0; - final artistAffinity = ((artistAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - final sourceAffinity = ((sourceAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - final sessionAlignment = _smartQueueSessionAlignment( - profile: _smartQueueSessionProfile, - candidate: candidate, - ); - final hourAffinityRaw = - _smartQueueHourAffinity[_currentSmartQueueHourBucket()] ?? 0.0; - final hourAffinity = ((hourAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - final tempoContinuity = _smartQueueTempoContinuity( - seed: seed, - candidate: candidate, - ); - final yearCohesion = _smartQueueYearCohesion( - profile: _smartQueueSessionProfile, - candidate: candidate, - ); - - var artistRepetition = 0; - final candidateArtist = _normalizeSmartQueueKey(candidate.artistName); - if (candidateArtist.isNotEmpty) { - for (final key in _recentPlayedTrackKeys.take(10)) { - if (key.contains('|$candidateArtist')) { - artistRepetition++; - } - } - for (final queueItem in state.queue.reversed.take(6)) { - final artist = _normalizeSmartQueueKey(queueItem.artist); - if (artist.isNotEmpty && artist == candidateArtist) { - artistRepetition++; - } - } - } - final novelty = (1.0 - (artistRepetition / 3.0)).clamp(0.15, 1.0); - - final alreadySeen = existingTrackKeys.contains( - _trackKeyFromTrack(candidate), - ); - final noveltyAfterDuplicateCheck = alreadySeen ? 0.0 : novelty; - final skipPressure = (_smartQueueSkipStreak / _smartQueueMaxSkipStreak) - .clamp(0.0, 1.0); - final skipContext = (1.0 - (sameArtist * skipPressure)).clamp(0.05, 1.0); - - return { - 'same_artist': sameArtist, - 'same_album': sameAlbum, - 'duration_similarity': durationSimilarity, - 'source_match': sourceMatch, - 'release_year_similarity': releaseYearSimilarity, - 'artist_affinity': artistAffinity, - 'source_affinity': sourceAffinity, - 'novelty': noveltyAfterDuplicateCheck, - 'session_alignment': sessionAlignment, - 'hour_affinity': hourAffinity, - 'skip_context': skipContext, - 'tempo_continuity': tempoContinuity, - 'year_cohesion': yearCohesion, - }; - } - - double _durationSimilarity(int aSec, int bSec) { - if (aSec <= 0 || bSec <= 0) return 0.5; - final maxSec = max(aSec, bSec).toDouble(); - final diff = (aSec - bSec).abs().toDouble(); - final normalized = (1.0 - (diff / maxSec)).clamp(0.0, 1.0); - return normalized; - } - - double _releaseYearSimilarity(String? a, String? b) { - final yearA = _parseYear(a); - final yearB = _parseYear(b); - if (yearA == null || yearB == null) return 0.5; - final diff = (yearA - yearB).abs(); - if (diff == 0) return 1.0; - if (diff <= 1) return 0.85; - if (diff <= 3) return 0.65; - if (diff <= 6) return 0.45; - return 0.2; - } - - int? _parseYear(String? raw) { - if (raw == null || raw.trim().isEmpty) return null; - final match = RegExp(r'(\d{4})').firstMatch(raw); - if (match == null) return null; - return int.tryParse(match.group(1)!); - } - - double _sessionExplorationCeiling() { - return switch (_smartQueueSessionProfile.mode) { - _SmartQueueSessionMode.focus => 0.03, - _SmartQueueSessionMode.chill => 0.045, - _SmartQueueSessionMode.energetic => 0.08, - _SmartQueueSessionMode.balanced => 0.06, - }; - } - - double _smartQueueSessionAlignment({ - required _SmartQueueSessionProfile profile, - required Track candidate, - }) { - final targetDuration = max(1, profile.targetDurationSec); - final durationDiff = (candidate.duration - targetDuration).abs().toDouble(); - final durationMatch = - (1.0 - (durationDiff / max(90.0, targetDuration.toDouble()))).clamp( - 0.0, - 1.0, - ); - final yearMatch = _smartQueueYearCohesion( - profile: profile, - candidate: candidate, - ); - final preferredSource = _normalizeSmartQueueKey(profile.preferredSourceKey); - final candidateSource = _sourceKey(candidate.source ?? ''); - final sourceMatch = - preferredSource.isEmpty || candidateSource == preferredSource - ? 1.0 - : 0.45; - return ((durationMatch * 0.55) + (yearMatch * 0.25) + (sourceMatch * 0.20)) - .clamp(0.0, 1.0); - } - - double _smartQueueYearCohesion({ - required _SmartQueueSessionProfile profile, - required Track candidate, - }) { - final targetYear = profile.targetYear; - final candidateYear = _parseYear(candidate.releaseDate); - if (targetYear == null || candidateYear == null) return 0.55; - final diff = (targetYear - candidateYear).abs(); - if (diff == 0) return 1.0; - if (diff <= 2) return 0.88; - if (diff <= 5) return 0.72; - if (diff <= 10) return 0.5; - if (diff <= 15) return 0.3; - return 0.1; - } - - double _smartQueueTempoContinuity({ - required Track seed, - required Track candidate, - }) { - final seedTempo = _smartQueueTempoHintForTrack(seed); - final candidateTempo = _smartQueueTempoHintForTrack(candidate); - if (seedTempo == null || candidateTempo == null) { - return _durationSimilarity( - seed.duration, - candidate.duration, - ).clamp(0.2, 1.0); - } - final diff = (seedTempo - candidateTempo).abs(); - if (diff <= 8) return 1.0; - if (diff <= 16) return 0.82; - if (diff <= 26) return 0.62; - if (diff <= _smartQueueMaxTempoJumpBpm) return 0.38; - return 0.12; - } - - double? _smartQueueTempoHintForTrack(Track track) { - final key = _trackKeyFromTrack(track); - if (key.isEmpty) return null; - final raw = _smartQueueTempoHintByTrackKey[key]; - if (raw == null || raw <= 0) return null; - return raw; - } - - String _sourceKey(String sourceRaw) { - final normalized = _normalizeSmartQueueKey(sourceRaw); - if (normalized.isNotEmpty) return normalized; - return _resolveService( - ref.read(settingsProvider).defaultService, - ).toLowerCase(); - } - - List<_SmartQueueCandidate> _selectSmartQueueCandidates({ - required Track seed, - required _SmartQueueSessionProfile sessionProfile, - required List<_SmartQueueCandidate> scored, - required int targetCount, - }) { - if (targetCount <= 0 || scored.isEmpty) return const []; - - final poolSize = min(scored.length, max(14, targetCount * 3)); - final pool = scored.take(poolSize).toList(growable: true); - final selected = <_SmartQueueCandidate>[]; - final artistCounts = _buildSmartQueueArtistBaselineCounts(); - final selectedKeys = {}; - - while (pool.isNotEmpty && selected.length < targetCount) { - final picked = _pickWeightedCandidate(pool); - pool.remove(picked); - if (selectedKeys.contains(picked.key)) { - continue; - } - - final artistKey = _normalizeSmartQueueKey(picked.track.artistName); - final repeats = artistCounts[artistKey] ?? 0; - if (artistKey.isNotEmpty && repeats >= _smartQueueMaxArtistRepeats) { - continue; - } - - if (!_passesSmartQueueConstraints( - seed: seed, - candidate: picked.track, - profile: sessionProfile, - )) { - continue; - } - - selected.add(picked); - selectedKeys.add(picked.key); - if (artistKey.isNotEmpty) { - artistCounts[artistKey] = repeats + 1; - } - } - - if (selected.isEmpty) { - final relaxedArtistLimit = _smartQueueMaxArtistRepeats + 1; - for (final candidate in scored) { - if (selected.length >= targetCount) break; - if (selectedKeys.contains(candidate.key)) continue; - - final artistKey = _normalizeSmartQueueKey(candidate.track.artistName); - final repeats = artistCounts[artistKey] ?? 0; - if (artistKey.isNotEmpty && repeats >= relaxedArtistLimit) { - continue; - } - - selected.add(candidate); - selectedKeys.add(candidate.key); - if (artistKey.isNotEmpty) { - artistCounts[artistKey] = repeats + 1; - } - } - } - - return selected; - } - - Map _buildSmartQueueArtistBaselineCounts() { - final counts = {}; - for (final item in state.queue.reversed.take(8)) { - final artistKey = _normalizeSmartQueueKey(item.artist); - if (artistKey.isEmpty) continue; - counts[artistKey] = (counts[artistKey] ?? 0) + 1; - } - for (final signal in _smartQueueSessionSignals.reversed.take(8)) { - final artistKey = signal.artistKey; - if (artistKey.isEmpty) continue; - counts[artistKey] = (counts[artistKey] ?? 0) + 1; - } - return counts; - } - - bool _passesSmartQueueConstraints({ - required Track seed, - required Track candidate, - required _SmartQueueSessionProfile profile, - }) { - final seedYear = _parseYear(seed.releaseDate); - final candidateYear = _parseYear(candidate.releaseDate); - if (seedYear != null && - candidateYear != null && - (seedYear - candidateYear).abs() > _smartQueueMaxDecadeDriftYears) { - return false; - } - - if (profile.targetYear != null && - candidateYear != null && - (profile.targetYear! - candidateYear).abs() > - _smartQueueMaxDecadeDriftYears) { - return false; - } - - final seedTempo = _smartQueueTempoHintForTrack(seed); - final candidateTempo = _smartQueueTempoHintForTrack(candidate); - if (seedTempo != null && - candidateTempo != null && - (seedTempo - candidateTempo).abs() > _smartQueueMaxTempoJumpBpm) { - return false; - } - - final seedDuration = max(1, seed.duration); - final candidateDuration = max(1, candidate.duration); - final durationRatio = candidateDuration / seedDuration; - if (durationRatio > 2.25 || durationRatio < 0.45) { - return false; - } - return true; - } - - _SmartQueueCandidate _pickWeightedCandidate(List<_SmartQueueCandidate> pool) { - if (pool.length == 1) return pool.first; - - var total = 0.0; - for (final item in pool) { - total += max(0.0001, item.score); - } - var cursor = _smartQueueRandom.nextDouble() * total; - for (final item in pool) { - cursor -= max(0.0001, item.score); - if (cursor <= 0) return item; - } - return pool.last; - } - - void _pruneSmartQueueCaches() { - final now = DateTime.now(); - _smartQueueSearchCache.removeWhere( - (_, value) => now.difference(value.fetchedAt) > _smartQueueSearchCacheTtl, - ); - _smartQueueRelatedArtistsCache.removeWhere( - (_, value) => now.difference(value.fetchedAt) > _smartQueueSearchCacheTtl, - ); - _smartQueuePendingFeedbackByTrack.removeWhere( - (_, value) => now.difference(value.addedAt) > _smartQueueFeedbackMaxAge, - ); - } - - Uri _uriFromPath(String path) { - final input = path.trim(); - if (input.startsWith('http://') || - input.startsWith('https://') || - input.startsWith('content://') || - input.startsWith('file://')) { - return Uri.parse(input); - } - return Uri.file(input); - } - - String _resolvePrefetchServiceBucket(PlaybackItem item) { - final itemService = item.service.trim().toLowerCase(); - if (_isBuiltInStreamingService(itemService)) { - return itemService; - } - - final trackSource = (item.track?.source ?? '').trim().toLowerCase(); - if (_isBuiltInStreamingService(trackSource)) { - return trackSource; - } - - final defaultService = _resolveService( - ref.read(settingsProvider).defaultService, - ).toLowerCase(); - if (_isBuiltInStreamingService(defaultService)) { - return defaultService; - } - return 'other'; - } - - int _defaultPrefetchResolveLatencyMs(String serviceBucket) { - switch (serviceBucket) { - case 'tidal': - return 16000; - case 'amazon': - return 15000; - case 'qobuz': - return 10000; - case 'youtube': - return 12000; - default: - return 10000; - } - } - - int _prefetchSafetyMarginMs(String serviceBucket) { - switch (serviceBucket) { - case 'tidal': - return 9000; - case 'amazon': - return 7000; - case 'qobuz': - return 5000; - case 'youtube': - return 6000; - default: - return 5000; - } - } - - int _estimatePrefetchResolveLatencyMs(String serviceBucket) { - final samples = _prefetchLatencyByServiceMs[serviceBucket]; - if (samples == null || samples.isEmpty) { - return _defaultPrefetchResolveLatencyMs(serviceBucket); - } - - final sorted = [...samples]..sort(); - final percentileIndex = (((sorted.length - 1) * 0.95).round()).clamp( - 0, - sorted.length - 1, - ); - return sorted[percentileIndex]; - } - - Duration _adaptivePrefetchThresholdFor(PlaybackItem nextItem) { - final serviceBucket = _resolvePrefetchServiceBucket(nextItem); - var triggerMs = - _estimatePrefetchResolveLatencyMs(serviceBucket) + - _prefetchSafetyMarginMs(serviceBucket); - if (serviceBucket == 'tidal') { - // DASH manifest flow typically needs earlier warmup than direct URLs. - triggerMs = max(triggerMs, 22000); - } - final clamped = triggerMs.clamp( - _prefetchThresholdFloor.inMilliseconds, - _prefetchThresholdCeiling.inMilliseconds, - ); - return Duration(milliseconds: clamped.toInt()); - } - - bool _shouldTriggerPrefetchAttempt({ - required int attempts, - required Duration position, - required Duration remaining, - required Duration threshold, - }) { - if (attempts >= _maxPrefetchAttemptsPerTrack) { - return false; - } - if (position < const Duration(seconds: 1) || remaining.isNegative) { - return false; - } - - final inLateWindow = remaining <= threshold; - if (attempts == 0) { - return inLateWindow || position >= _prefetchEarlyKickoffPosition; - } - - // Retry only close to track end to avoid repeated resolver load. - return inLateWindow; - } - - void _maybePrefetchNext(Duration position) { - if (state.isLoading || state.currentIndex < 0 || state.queue.isEmpty) { - return; - } - final duration = state.duration; - if (duration <= Duration.zero) return; - - final nextIndex = _peekNextIndexForPrefetch(); - if (nextIndex == null) return; - if (nextIndex < 0 || nextIndex >= state.queue.length) return; - if (_prefetchingQueueIndex == nextIndex && - _lastPrefetchAttemptIndex == nextIndex) { - return; - } - - final nextItem = state.queue[nextIndex]; - if (nextItem.sourceUri.isNotEmpty || - nextItem.track == null || - nextItem.isLocal) { - return; - } - - final remaining = duration - position; - final adaptiveThreshold = _adaptivePrefetchThresholdFor(nextItem); - final attempts = _prefetchAttemptCounts[nextIndex] ?? 0; - if (!_shouldTriggerPrefetchAttempt( - attempts: attempts, - position: position, - remaining: remaining, - threshold: adaptiveThreshold, - )) { - return; - } - - final lastAttemptAt = _prefetchLastAttemptAt[nextIndex]; - if (lastAttemptAt != null && - DateTime.now().difference(lastAttemptAt) < _prefetchRetryCooldown) { - return; - } - - _prefetchAttemptCounts[nextIndex] = attempts + 1; - _prefetchLastAttemptAt[nextIndex] = DateTime.now(); - _lastPrefetchAttemptIndex = nextIndex; - unawaited(_prefetchQueueIndex(nextIndex)); - } - - int? _peekNextIndexForPrefetch() { - if (state.queue.isEmpty) return null; - - if (state.shuffle) { - final nextPos = _shufflePosition + 1; - if (nextPos < _shuffleOrder.length) { - return _shuffleOrder[nextPos]; - } - if (state.repeatMode == RepeatMode.all && _shuffleOrder.isNotEmpty) { - return _shuffleOrder.first; - } - return null; - } - - final next = state.currentIndex + 1; - if (next < state.queue.length) return next; - if (state.repeatMode == RepeatMode.all) return 0; - return null; - } - - Future _prefetchQueueIndex(int index) async { - if (index < 0) return; - } - - String _resolveService(String defaultService) { - final selected = defaultService.trim(); - if (selected.isEmpty) { - return 'tidal'; - } - final normalized = selected.toLowerCase(); - if (_isBuiltInStreamingService(normalized)) { - return normalized; - } - return selected; - } - - bool _isBuiltInStreamingService(String service) { - switch (service) { - case 'tidal': - case 'qobuz': - case 'amazon': - case 'youtube': - return true; - default: - return false; - } - } - - void _setPlaybackError(String message, {String type = 'resolve_failed'}) { - final trimmed = message.trim(); - state = state.copyWith( - isLoading: false, - isPlaying: false, - isBuffering: false, - error: trimmed.isEmpty ? 'Playback error' : trimmed, - errorType: type, - ); - } - - bool _shouldAutoSkipQueueItemOnFailure(String? failureType) { - final settings = ref.read(settingsProvider); - if (!settings.autoSkipUnavailableTracks) { - return false; - } - final normalized = (failureType ?? '').trim().toLowerCase(); - return normalized == 'not_found' || normalized == 'resolve_failed'; - } - - int? _resolveNextQueueIndexWithoutWrapAfterFailure(int failedIndex) { - if (failedIndex < 0 || failedIndex >= state.queue.length) return null; - - if (state.shuffle) { - final failedShufflePos = _shuffleOrder.indexOf(failedIndex); - if (failedShufflePos < 0) return null; - final nextShufflePos = failedShufflePos + 1; - if (nextShufflePos >= _shuffleOrder.length) return null; - return _shuffleOrder[nextShufflePos]; - } - - final nextIndex = failedIndex + 1; - if (nextIndex >= state.queue.length) return null; - return nextIndex; - } - - Future _handleQueueItemPlaybackFailure({ - required int failedIndex, - required int expectedRequestEpoch, - required Object error, - String fallbackType = 'resolve_failed', - }) async { - if (!_isPlayRequestCurrent(expectedRequestEpoch)) { - return false; - } - - final hasExistingError = (state.error ?? '').trim().isNotEmpty; - if (hasExistingError) { - state = state.copyWith( - isLoading: false, - isPlaying: false, - isBuffering: false, - ); - } else { - _setPlaybackError('Failed to play: $error', type: fallbackType); - } - - if (!_isPlayRequestCurrent(expectedRequestEpoch) || - state.currentIndex != failedIndex || - !_shouldAutoSkipQueueItemOnFailure(state.errorType)) { - return false; - } - - final nextIndex = _resolveNextQueueIndexWithoutWrapAfterFailure( - failedIndex, - ); - if (nextIndex == null || nextIndex == failedIndex) { - return false; - } - - final failureMessage = (state.error ?? '').trim(); - _log.w( - 'Auto-skip queue item $failedIndex -> $nextIndex ' - 'after ${state.errorType ?? fallbackType}: ' - '${failureMessage.isNotEmpty ? failureMessage : error}', - ); - await _playQueueIndex(nextIndex); - return true; - } - - bool _inferSeekSupportedForQueueItem(PlaybackItem item) { - if (item.isLocal) return true; - - final service = item.service.trim().toLowerCase(); - final trackSource = (item.track?.source ?? '').trim().toLowerCase(); - final resolvedService = service.isNotEmpty ? service : trackSource; - if (resolvedService == 'youtube') return false; - - final sourceUri = item.sourceUri.trim(); - if (sourceUri.isNotEmpty && - FFmpegService.isActiveLiveDecryptedUrl(sourceUri)) { - return false; - } - - return true; - } - - Duration? _pendingResumePositionForIndex(int index) { - final pendingPosition = _pendingResumePosition; - final pendingIndex = _pendingResumeIndex; - if (pendingPosition == null || - pendingPosition <= Duration.zero || - pendingIndex != index) { - return null; - } - return pendingPosition; - } - - void _clearPendingResumeForIndex(int index) { - if (_pendingResumeIndex != index) return; - _pendingResumePosition = null; - _pendingResumeIndex = null; - } - - void _scheduleSnapshotSaveForProgress(Duration position) { - if (state.queue.isEmpty || state.currentIndex < 0) return; - if (_player.processingState == ProcessingState.idle) return; - - final ms = position.inMilliseconds; - if (_lastProgressSnapshotMs >= 0 && - (ms - _lastProgressSnapshotMs).abs() < 1500) { - return; - } - _lastProgressSnapshotMs = ms; - - _snapshotSaveTimer?.cancel(); - _snapshotSaveTimer = Timer(const Duration(milliseconds: 300), () { - unawaited(_savePlaybackSnapshot()); - }); - } - - void _disposeInternal() { - _appLifecycleListener?.dispose(); - _appLifecycleListener = null; - _snapshotSaveTimer?.cancel(); - _smartQueueModelSaveTimer?.cancel(); - unawaited(_savePlaybackSnapshot()); - unawaited(_persistSmartQueueModel()); - unawaited(FFmpegService.stopLiveDecryptedStream()); - unawaited(FFmpegService.stopNativeDashManifestPlayback()); - for (final sub in _subscriptions) { - sub.cancel(); - } - _player.dispose(); - } -} - -class _SmartQueueLearningContext { - final Map features; - final DateTime addedAt; - - const _SmartQueueLearningContext({ - required this.features, - required this.addedAt, - }); -} - -enum _SmartQueueSessionMode { balanced, focus, chill, energetic } - -class _SmartQueueSessionProfile { - final _SmartQueueSessionMode mode; - final int targetDurationSec; - final int? targetYear; - final String preferredSourceKey; - - const _SmartQueueSessionProfile({ - required this.mode, - required this.targetDurationSec, - this.targetYear, - this.preferredSourceKey = '', - }); -} - -class _SmartQueueSessionSignal { - final String artistKey; - final String sourceKey; - final int durationSec; - final int? releaseYear; - final double listenRatio; - final bool skipped; - - const _SmartQueueSessionSignal({ - required this.artistKey, - required this.sourceKey, - required this.durationSec, - required this.releaseYear, - required this.listenRatio, - required this.skipped, - }); -} - -class _SmartQueueCachedResult { - final List tracks; - final DateTime fetchedAt; - - const _SmartQueueCachedResult({ - required this.tracks, - required this.fetchedAt, - }); -} - -class _SmartQueueRelatedArtistsCache { - final List<_SmartQueueRelatedArtist> artists; - final DateTime fetchedAt; - - const _SmartQueueRelatedArtistsCache({ - required this.artists, - required this.fetchedAt, - }); -} - -class _SmartQueueRelatedArtist { - final String name; - final String provider; - final double score; - - const _SmartQueueRelatedArtist({ - required this.name, - required this.provider, - required this.score, - }); -} - -class _SmartQueueArtistSeed { - final String id; - final String name; - final String provider; - final double score; - - const _SmartQueueArtistSeed({ - required this.id, - required this.name, - required this.provider, - required this.score, - }); -} - -class _SmartQueueCandidate { - final Track track; - final String key; - final Map features; - final double score; - - const _SmartQueueCandidate({ - required this.track, - required this.key, - required this.features, - required this.score, - }); -} - -class _OfflineSmartQueueTrackHit { - final Track track; - final double score; - - const _OfflineSmartQueueTrackHit({required this.track, required this.score}); -} - -class _OfflineSmartQueueArtistStats { - final String name; - final int count; - final double scoreSum; - - const _OfflineSmartQueueArtistStats({ - required this.name, - required this.count, - required this.scoreSum, - }); } final playbackProvider = NotifierProvider( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 7bad99bc..133bdeec 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -279,22 +279,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setAutoSkipUnavailableTracks(bool enabled) { - state = state.copyWith(autoSkipUnavailableTracks: enabled); - _saveSettings(); - } - - void setPlayerMode(String mode) { - final normalized = mode == 'external' ? 'external' : 'internal'; - state = state.copyWith(playerMode: normalized); - _saveSettings(); - } - - void setSmartQueueEnabled(bool enabled) { - state = state.copyWith(smartQueueEnabled: enabled); - _saveSettings(); - } - void setEmbedLyrics(bool enabled) { state = state.copyWith(embedLyrics: enabled); _saveSettings(); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 3d66b196..d2442c22 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -10,7 +10,6 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -793,12 +792,11 @@ class _LibraryTracksFolderScreenState Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (widget.mode != - LibraryTracksFolderMode.wishlist) ...[ - _buildShuffleButton(entries), - const SizedBox(width: 12), - ], - _buildPlayAllCenterButton(entries), + _buildHeaderActionPlaceholder(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(entries), + const SizedBox(width: 12), + _buildHeaderActionPlaceholder(), ], ), ], @@ -831,35 +829,16 @@ class _LibraryTracksFolderScreenState ); } - // ── Shuffle / Play buttons ── + // ── Header actions ── - Widget _buildShuffleButton(List entries) { - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.15), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 1, - ), - ), - child: IconButton( - onPressed: entries.isEmpty ? null : () => _shufflePlay(entries), - icon: const Icon(Icons.shuffle_rounded, size: 22, color: Colors.white), - tooltip: 'Shuffle Play', - padding: EdgeInsets.zero, - ), - ); - } + Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); - Widget _buildPlayAllCenterButton(List entries) { + Widget _buildDownloadAllCenterButton(List entries) { final tracks = entries.map((e) => e.track).toList(growable: false); return FilledButton.icon( - onPressed: tracks.isEmpty ? null : () => _playAll(tracks), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text(context.l10n.playAllCount(tracks.length)), + onPressed: tracks.isEmpty ? null : () => _confirmDownloadAll(tracks), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(tracks.length)), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, @@ -869,28 +848,70 @@ class _LibraryTracksFolderScreenState ); } - void _shufflePlay(List entries) { - final tracks = entries.map((e) => e.track).toList(growable: false); + void _confirmDownloadAll(List tracks) { if (tracks.isEmpty) return; - final shuffled = [...tracks]..shuffle(); - final messenger = ScaffoldMessenger.of(context); - ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) { - if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Cannot shuffle play local tracks: $e')), - ); - }); + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: const Text('Download All'), + content: Text('Download ${tracks.length} tracks?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _downloadAll(tracks); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); } - void _playAll(List tracks) { + void _downloadAll(List tracks) { if (tracks.isEmpty) return; - final messenger = ScaffoldMessenger.of(context); - ref.read(playbackProvider.notifier).playTrackList(tracks).catchError((e) { - if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Cannot play local tracks: $e')), + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist, + }, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, service, qualityOverride: quality); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(tracks.length), + ), + ), + ); + }, ); - }); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + ), + ); + } } void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { @@ -1000,6 +1021,32 @@ class _CollectionTrackTile extends ConsumerWidget { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; final effectiveCoverUrl = _resolveCoverUrl(track); + final isInHistory = ref.watch( + downloadHistoryProvider.select((state) { + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; + }), + ); + final showLocalLibraryIndicator = ref.watch( + settingsProvider.select( + (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, + ), + ); + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ), + ) + : false; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1059,10 +1106,48 @@ class _CollectionTrackTile extends ConsumerWidget { ], ), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, + subtitle: Row( + children: [ + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isInLocalLibrary || isInHistory) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], ), trailing: isSelectionMode ? null diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 42e20dd2..e103c6a1 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -20,7 +20,6 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; -import 'package:spotiflac_android/widgets/mini_player_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -375,7 +374,7 @@ class _MainShellState extends ConsumerState { if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { _log.i('Back: step 8 - double-tap exit'); - SystemNavigator.pop(); + unawaited(PlatformBridge.exitApp()); } else { _log.i('Back: step 7 - first tap, showing exit snackbar'); _lastBackPress = now; @@ -487,28 +486,17 @@ class _MainShellState extends ConsumerState { }); } - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) { - return; - } - + return BackButtonListener( + onBackButtonPressed: () async { _handleBackPress(); + return true; }, child: Scaffold( - body: Column( - children: [ - Expanded( - child: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, - physics: const NeverScrollableScrollPhysics(), - children: tabs, - ), - ), - const MiniPlayerBar(), - ], + body: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + children: tabs, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index ba8d7bc8..1765647c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -149,6 +149,12 @@ class AboutPage extends StatelessWidget { subtitle: 'Partner lyrics proxy for Apple Music and QQ Music sources', onTap: () => _launchUrl('https://lyrics.paxsenix.org'), + showDivider: true, + ), + _ContributorItem( + name: 'Ruubiiiii', + description: 'Provided Qobuz API for the project', + githubUsername: 'Ruubiiiii', showDivider: false, ), ], diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index a1933808..4f20d118 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -166,19 +166,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = [ - 'NinoBrown', - '@nino_sandzak', - 'IMJ', - 'J', - 'Julian', - 'matt_3050', - 'Daniel', - '283Fabio', - 'laflame', - 'Elias el Autentico', - 'Faylyne', - ]; + const donorNames = []; // Match SettingsGroup color logic final cardColor = isDark @@ -221,16 +209,39 @@ class _RecentDonorsCard extends StatelessWidget { ), ), const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: donorNames - .map( - (name) => - _SupporterChip(name: name, colorScheme: colorScheme), - ) - .toList(), - ), + if (donorNames.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Icon( + Icons.emoji_events_outlined, + size: 32, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Text( + 'No supporters yet — be the first!', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: donorNames + .map( + (name) => + _SupporterChip(name: name, colorScheme: colorScheme), + ) + .toList(), + ), ], ), ), @@ -463,8 +474,8 @@ int _cr(String v) { for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } return r; } -// Highlighted supporters (hashes of names): Julian, J, NinoBrown, @nino_sandzak, IMJ. -const _cv = {1825257268, 1035, 1497948283, 398058782, 996135}; +// Highlighted supporters (hashes of names): none for now. +const _cv = {}; class _SupporterChip extends StatelessWidget { final String name; diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 6a33934c..a5a6e07c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -152,40 +152,6 @@ class OptionsSettingsPage extends ConsumerWidget { onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), ), - SettingsSwitchItem( - icon: Icons.skip_next_rounded, - title: context.l10n.optionsAutoSkipUnavailableTracks, - subtitle: settings.autoSkipUnavailableTracks - ? context - .l10n - .optionsAutoSkipUnavailableTracksSubtitleOn - : context - .l10n - .optionsAutoSkipUnavailableTracksSubtitleOff, - value: settings.autoSkipUnavailableTracks, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setAutoSkipUnavailableTracks(v), - ), - SettingsItem( - icon: Icons.headphones, - title: 'Music Player', - subtitle: _playerModeLabel(settings.playerMode), - onTap: () => _showPlayerModePicker( - context, - ref, - settings.playerMode, - ), - ), - SettingsSwitchItem( - icon: Icons.queue_music_rounded, - title: context.l10n.settingsSmartQueueTitle, - subtitle: context.l10n.settingsSmartQueueSubtitle, - value: settings.smartQueueEnabled, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setSmartQueueEnabled(v), - ), if (hasExtensions) SettingsSwitchItem( icon: Icons.extension, @@ -328,74 +294,6 @@ class OptionsSettingsPage extends ConsumerWidget { ); } - String _playerModeLabel(String mode) { - if (mode == 'external') { - return 'External app (Poweramp, etc.)'; - } - return 'Internal player'; - } - - void _showPlayerModePicker( - BuildContext context, - WidgetRef ref, - String currentMode, - ) { - showModalBottomSheet( - context: context, - useRootNavigator: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (sheetContext) { - final colorScheme = Theme.of(sheetContext).colorScheme; - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(999), - ), - ), - const SizedBox(height: 12), - ListTile( - leading: const Icon(Icons.play_circle_outline), - title: const Text('Internal Player'), - subtitle: const Text('Use built-in app playback and queue'), - trailing: currentMode == 'internal' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setPlayerMode('internal'); - Navigator.pop(sheetContext); - }, - ), - ListTile( - leading: const Icon(Icons.open_in_new), - title: const Text('External Player'), - subtitle: const Text( - 'Open songs with apps like Poweramp, Musicolet, etc.', - ), - trailing: currentMode == 'external' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setPlayerMode('external'); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ); - }, - ); - } - void _showClearHistoryDialog( BuildContext context, WidgetRef ref, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6710df4b..a0b35d54 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -142,6 +142,10 @@ class PlatformBridge { }); } + static Future exitApp() async { + await _channel.invokeMethod('exitApp'); + } + static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 3a2fb50f..bc9335c4 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -74,6 +74,13 @@ class UpdateChecker { return null; } + // Ignore releases from a different major version (e.g. v4.x when we + // rolled back to v3.x). Only offer updates within the same major line. + if (_majorVersion(latestVersion) != _majorVersion(AppInfo.version)) { + _log.i('Skipping update from different major version (current: ${AppInfo.version}, latest: $latestVersion)'); + return null; + } + final body = releaseData['body'] as String? ?? 'No changelog available'; final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); @@ -118,6 +125,14 @@ class UpdateChecker { } } + static int _majorVersion(String version) { + try { + return int.parse(version.split('-').first.split('.').first); + } catch (_) { + return -1; + } + } + static bool _isNewerVersion(String latest, String current) { try { final latestBase = latest.split('-').first; diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart deleted file mode 100644 index 3a31bd2d..00000000 --- a/lib/widgets/mini_player_bar.dart +++ /dev/null @@ -1,2040 +0,0 @@ -import 'dart:io'; -import 'dart:async'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/playback_item.dart'; -import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart' - as playback_types - show RepeatMode; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/widgets/download_service_picker.dart'; -import 'package:spotiflac_android/utils/clickable_metadata.dart'; - -const Set _builtInPlaybackSources = { - 'deezer', - 'spotify', - 'tidal', - 'qobuz', - 'amazon', - 'youtube', - 'ytmusic', - 'local', -}; - -String? _playbackItemExtensionId(PlaybackItem item) { - final source = (item.track?.source ?? '').trim(); - if (source.isEmpty) return null; - if (_builtInPlaybackSources.contains(source.toLowerCase())) return null; - return source; -} - -// ─── Mini Player Bar ───────────────────────────────────────────────────────── -class MiniPlayerBar extends ConsumerWidget { - const MiniPlayerBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final stateSnapshot = ref.watch( - playbackProvider.select( - (s) => ( - currentItem: s.currentItem, - isPlaying: s.isPlaying, - isBuffering: s.isBuffering, - isLoading: s.isLoading, - hasNext: s.hasNext, - repeatMode: s.repeatMode, - error: s.error, - errorType: s.errorType, - ), - ), - ); - final playbackError = _localizedPlaybackErrorFromRaw( - context, - stateSnapshot.error, - stateSnapshot.errorType, - ); - final item = stateSnapshot.currentItem; - if (item == null) return const SizedBox.shrink(); - - final colorScheme = Theme.of(context).colorScheme; - - return Material( - color: colorScheme.surfaceContainerHighest, - child: InkWell( - onTap: () => _showExpandedPlayer(context), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const _MiniPlayerProgressBar(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Cover art - _CoverArt( - url: item.coverUrl, - isLocal: item.hasLocalCover, - size: 40, - borderRadius: 8, - ), - const SizedBox(width: 10), - // Track info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - item.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - // Error indicator - if (playbackError != null) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - Icons.error_outline_rounded, - size: 20, - color: colorScheme.error, - ), - ), - // Loading indicator - if (stateSnapshot.isBuffering || stateSnapshot.isLoading) - const Padding( - padding: EdgeInsets.only(right: 8), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - // Play / Pause - IconButton( - icon: Icon( - stateSnapshot.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - onPressed: () => - ref.read(playbackProvider.notifier).togglePlayPause(), - ), - // Next - if (stateSnapshot.hasNext || - stateSnapshot.repeatMode == playback_types.RepeatMode.all) - IconButton( - icon: const Icon(Icons.skip_next_rounded, size: 22), - onPressed: () => - ref.read(playbackProvider.notifier).skipNext(), - ), - // Close - IconButton( - icon: const Icon(Icons.close_rounded, size: 20), - onPressed: () => - ref.read(playbackProvider.notifier).dismissPlayer(), - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ], - ), - ), - ); - } - - void _showExpandedPlayer(BuildContext context) { - Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - pageBuilder: (context, animation, secondaryAnimation) => - const _FullScreenPlayer(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return SlideTransition( - position: Tween(begin: const Offset(0, 1), end: Offset.zero) - .animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - ), - child: child, - ); - }, - transitionDuration: const Duration(milliseconds: 350), - reverseTransitionDuration: const Duration(milliseconds: 300), - ), - ); - } -} - -class _MiniPlayerProgressBar extends ConsumerWidget { - const _MiniPlayerProgressBar(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final progressState = ref.watch( - playbackProvider.select( - (s) => (position: s.position, duration: s.duration), - ), - ); - final colorScheme = Theme.of(context).colorScheme; - final durationMs = progressState.duration.inMilliseconds; - final positionMs = progressState.position.inMilliseconds.clamp( - 0, - durationMs > 0 ? durationMs : 0, - ); - final progress = durationMs > 0 ? positionMs / durationMs : 0.0; - - return LinearProgressIndicator( - value: progress, - minHeight: 2, - backgroundColor: colorScheme.surfaceContainerHighest, - ); - } -} - -// ─── Full-Screen Player ────────────────────────────────────────────────────── -class _FullScreenPlayer extends ConsumerStatefulWidget { - const _FullScreenPlayer(); - - @override - ConsumerState<_FullScreenPlayer> createState() => _FullScreenPlayerState(); -} - -class _FullScreenPlayerState extends ConsumerState<_FullScreenPlayer> { - // 0 = cover art view, 1 = lyrics view - int _currentPage = 0; - late final PageController _pageController; - bool _isScrubbing = false; - double _scrubSeconds = 0; - double _topBarDragOffset = 0; - String? _lastLyricsPrefetchKey; - AppLifecycleListener? _appLifecycleListener; - bool _isAppResumed = true; - - @override - void initState() { - super.initState(); - _pageController = PageController(); - final initialState = WidgetsBinding.instance.lifecycleState; - _isAppResumed = - initialState == null || initialState == AppLifecycleState.resumed; - _appLifecycleListener = AppLifecycleListener( - onResume: () { - _isAppResumed = true; - if (!mounted) return; - final state = ref.read(playbackProvider); - _prefetchLyricsForCurrentTrack(state); - }, - onPause: () => _isAppResumed = false, - onHide: () => _isAppResumed = false, - onDetach: () => _isAppResumed = false, - onInactive: () => _isAppResumed = false, - ); - } - - @override - void dispose() { - _appLifecycleListener?.dispose(); - _appLifecycleListener = null; - _pageController.dispose(); - super.dispose(); - } - - String _lyricsPrefetchKey(PlaybackItem item) { - return '${item.id}|${item.title}|${item.artist}'; - } - - void _prefetchLyricsForCurrentTrack(PlaybackState state) { - if (!_isAppResumed) return; - final item = state.currentItem; - if (item == null) return; - - final key = _lyricsPrefetchKey(item); - if (_lastLyricsPrefetchKey == key) return; - _lastLyricsPrefetchKey = key; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - unawaited(ref.read(playbackProvider.notifier).ensureLyricsLoaded()); - }); - } - - void _switchToLyrics() { - setState(() => _currentPage = 1); - _pageController.animateToPage( - 1, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } - - void _switchToCover() { - setState(() => _currentPage = 0); - _pageController.animateToPage( - 0, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } - - void _handleTopBarDragUpdate(DragUpdateDetails details) { - final delta = details.primaryDelta ?? 0; - if (delta <= 0) return; - _topBarDragOffset += delta; - } - - void _handleTopBarDragEnd(DragEndDetails details) { - final swipeVelocity = details.primaryVelocity ?? 0; - final shouldDismiss = _topBarDragOffset > 72 || swipeVelocity > 900; - _topBarDragOffset = 0; - if (!shouldDismiss) return; - if (!mounted) return; - Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(playbackProvider); - final playbackNotifier = ref.read(playbackProvider.notifier); - final displayOrder = playbackNotifier.getQueueDisplayOrder(); - final displayPosition = playbackNotifier.getCurrentDisplayQueuePosition( - displayOrder: displayOrder, - ); - final queuePositionLabel = displayPosition >= 0 - ? displayPosition + 1 - : state.currentIndex + 1; - final playbackError = _localizedPlaybackError(context, state); - final item = state.currentItem; - if (item == null) { - _lastLyricsPrefetchKey = null; - // Track stopped, close the player - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).pop(); - }); - return const SizedBox.shrink(); - } - _prefetchLyricsForCurrentTrack(state); - final extensionId = _playbackItemExtensionId(item); - - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final screenSize = MediaQuery.sizeOf(context); - final isLandscape = screenSize.width > screenSize.height; - - final duration = state.duration; - final position = state.position; - final maxSeconds = duration.inMilliseconds > 0 - ? duration.inSeconds.toDouble() - : 0.0; - final currentSeconds = position.inSeconds.toDouble().clamp( - 0.0, - maxSeconds > 0 ? maxSeconds : 0.0, - ); - final sliderSeconds = _isScrubbing - ? _scrubSeconds.clamp(0.0, maxSeconds > 0 ? maxSeconds : 0.0) - : currentSeconds; - - return Scaffold( - backgroundColor: colorScheme.surface, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isCompactLayout = isLandscape || constraints.maxHeight < 620; - final mediaSectionHeight = - (constraints.maxHeight * (isCompactLayout ? 0.32 : 0.50)).clamp( - isCompactLayout ? 140.0 : 260.0, - isCompactLayout ? 280.0 : 560.0, - ); - final horizontalPadding = isCompactLayout ? 16.0 : 24.0; - final verticalGap = isCompactLayout ? 2.0 : 4.0; - final showAlbum = item.album.isNotEmpty && !isCompactLayout; - - return SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Column( - children: [ - // ── Top bar (close + title + lyrics toggle) - GestureDetector( - behavior: HitTestBehavior.opaque, - onVerticalDragUpdate: _handleTopBarDragUpdate, - onVerticalDragEnd: _handleTopBarDragEnd, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: isCompactLayout ? 2 : 4, - ), - child: Row( - children: [ - // ── Left side - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: IconButton( - icon: const Icon( - Icons.keyboard_arrow_down_rounded, - size: 30, - ), - visualDensity: isCompactLayout - ? VisualDensity.compact - : VisualDensity.standard, - onPressed: () => Navigator.of(context).pop(), - tooltip: 'Close', - ), - ), - ), - // ── Center: Queue info - if (state.queue.length > 1) - GestureDetector( - onTap: () => _showQueueSheet(context, ref), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.queue_music_rounded, - size: 16, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 6), - Text( - '$queuePositionLabel / ${state.queue.length}', - style: textTheme.labelMedium?.copyWith( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - // ── Right side - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (!item.isLocal && item.track != null) - _DownloadButton( - item: item, - compact: isCompactLayout, - ), - IconButton( - visualDensity: isCompactLayout - ? VisualDensity.compact - : VisualDensity.standard, - icon: Icon( - Icons.lyrics_outlined, - color: _currentPage == 1 - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - onPressed: () { - if (_currentPage == 0) { - _switchToLyrics(); - } else { - _switchToCover(); - } - }, - tooltip: 'Lyrics', - ), - ], - ), - ), - ], - ), - ), - ), - - // ── Main content area (swipeable cover / lyrics) - SizedBox( - height: mediaSectionHeight, - child: PageView( - controller: _pageController, - onPageChanged: (page) => - setState(() => _currentPage = page), - children: [ - // Page 0: Cover art - _CoverArtPage(item: item, colorScheme: colorScheme), - // Page 1: Lyrics - _LyricsPage( - state: state, - colorScheme: colorScheme, - onRetry: () => ref - .read(playbackProvider.notifier) - .refetchLyrics(), - onSeek: state.seekSupported - ? (ms) => ref - .read(playbackProvider.notifier) - .seek(Duration(milliseconds: ms)) - : null, - ), - ], - ), - ), - - // ── Page indicator dots - Padding( - padding: EdgeInsets.only(top: isCompactLayout ? 4 : 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _PageDot( - active: _currentPage == 0, - colorScheme: colorScheme, - ), - const SizedBox(width: 6), - _PageDot( - active: _currentPage == 1, - colorScheme: colorScheme, - ), - ], - ), - ), - SizedBox(height: isCompactLayout ? 4 : 8), - - // ── Track info - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Row( - children: [ - const SizedBox(width: 48), - Expanded( - child: Column( - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: - (isCompactLayout - ? textTheme.titleMedium - : textTheme.titleLarge) - ?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: verticalGap), - ClickableArtistName( - artistName: item.artist, - artistId: item.track?.artistId, - extensionId: extensionId, - coverUrl: item.coverUrl.isNotEmpty - ? item.coverUrl - : null, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - (isCompactLayout - ? textTheme.bodySmall - : textTheme.bodyMedium) - ?.copyWith( - color: colorScheme.primary, - ), - ), - if (showAlbum) ...[ - const SizedBox(height: 2), - ClickableAlbumName( - albumName: item.album, - albumId: item.track?.albumId, - artistName: item.artist, - extensionId: extensionId, - coverUrl: item.coverUrl.isNotEmpty - ? item.coverUrl - : null, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - ), - ], - ], - ), - ), - SizedBox( - width: 48, - child: item.track != null - ? Consumer( - builder: (context, ref, child) { - final isLoved = ref.watch( - libraryCollectionsProvider.select( - (s) => s.isLoved(item.track!), - ), - ); - return IconButton( - icon: Icon( - isLoved - ? Icons.favorite - : Icons.favorite_border, - size: isCompactLayout ? 24 : 28, - ), - color: isLoved - ? Colors.redAccent - : colorScheme.onSurfaceVariant, - onPressed: () => ref - .read( - libraryCollectionsProvider - .notifier, - ) - .toggleLoved(item.track!), - ); - }, - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - SizedBox(height: verticalGap), - - // ── Quality + Service badge row - _QualityServiceRow(item: item, colorScheme: colorScheme), - SizedBox(height: verticalGap), - - // ── Error message - if (playbackError != null) - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalGap, - ), - child: Text( - playbackError, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - - // ── Seek slider - Padding( - padding: EdgeInsets.symmetric( - horizontal: isCompactLayout ? 12 : 16, - ), - child: SliderTheme( - data: SliderThemeData( - trackHeight: 3, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 14, - ), - activeTrackColor: colorScheme.primary, - inactiveTrackColor: colorScheme.primary.withValues( - alpha: 0.15, - ), - ), - child: Slider( - value: sliderSeconds, - max: maxSeconds > 0 ? maxSeconds : 1, - onChangeStart: state.seekSupported && maxSeconds > 0 - ? (value) { - setState(() { - _isScrubbing = true; - _scrubSeconds = value; - }); - } - : null, - onChanged: state.seekSupported - ? (value) { - if (!_isScrubbing) { - setState(() { - _isScrubbing = true; - }); - } - setState(() { - _scrubSeconds = value; - }); - } - : null, - onChangeEnd: state.seekSupported - ? (value) async { - setState(() { - _scrubSeconds = value; - _isScrubbing = false; - }); - await ref - .read(playbackProvider.notifier) - .seek( - Duration( - milliseconds: (value * 1000).round(), - ), - ); - } - : null, - ), - ), - ), - - // ── Duration labels - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(position), - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - _formatDuration(duration), - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - SizedBox(height: verticalGap), - - // ── Playback controls - _PlaybackControls(state: state, compact: isCompactLayout), - SizedBox(height: verticalGap), - ], - ), - ), - ); - }, - ), - ), - ); - } - - String _formatDuration(Duration duration) { - final totalSeconds = duration.inSeconds; - final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); - final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } - - void _showQueueSheet(BuildContext context, WidgetRef ref) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => _QueueBottomSheet(ref: ref), - ); - } -} - -String? _localizedPlaybackError(BuildContext context, PlaybackState state) { - return _localizedPlaybackErrorFromRaw(context, state.error, state.errorType); -} - -String? _localizedPlaybackErrorFromRaw( - BuildContext context, - String? error, - String? errorType, -) { - final raw = (error ?? '').trim(); - if (raw.isEmpty) { - return null; - } - if (errorType == 'seek_not_supported') { - return context.l10n.errorSeekNotSupported; - } - if (errorType == 'not_found') { - return context.l10n.errorNoTracksFound; - } - return raw; -} - -// ─── Page dot indicator ────────────────────────────────────────────────────── -class _PageDot extends StatelessWidget { - final bool active; - final ColorScheme colorScheme; - - const _PageDot({required this.active, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: active ? 16 : 6, - height: 6, - decoration: BoxDecoration( - color: active ? colorScheme.primary : colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(3), - ), - ); - } -} - -// ─── Cover Art Page ────────────────────────────────────────────────────────── -class _CoverArtPage extends StatelessWidget { - final PlaybackItem item; - final ColorScheme colorScheme; - - const _CoverArtPage({required this.item, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: AspectRatio( - aspectRatio: 1, - child: _CoverArt( - url: item.coverUrl, - isLocal: item.hasLocalCover, - size: double.infinity, - borderRadius: 20, - ), - ), - ), - ); - } -} - -// ─── Lyrics Page ───────────────────────────────────────────────────────────── -class _LyricsPage extends StatelessWidget { - final PlaybackState state; - final ColorScheme colorScheme; - final VoidCallback onRetry; - final ValueChanged? onSeek; - - const _LyricsPage({ - required this.state, - required this.colorScheme, - required this.onRetry, - required this.onSeek, - }); - - @override - Widget build(BuildContext context) { - if (state.lyricsLoading) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(height: 12), - Text( - 'Loading lyrics...', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - final lyrics = state.lyrics; - if (lyrics == null || lyrics.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lyrics_outlined, - size: 48, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - ), - const SizedBox(height: 12), - Text( - 'No lyrics available', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - TextButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Retry'), - ), - ], - ), - ); - } - - if (lyrics.instrumental) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note_rounded, - size: 48, - color: colorScheme.primary.withValues(alpha: 0.6), - ), - const SizedBox(height: 12), - Text( - 'Instrumental', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } - - if (lyrics.isSynced) { - return _SyncedLyricsView( - lyrics: lyrics, - positionMs: state.position.inMilliseconds, - colorScheme: colorScheme, - onSeek: onSeek, - ); - } - - // Unsynced lyrics: simple scrollable text - return _UnsyncedLyricsView(lyrics: lyrics, colorScheme: colorScheme); - } -} - -// ─── Synced Lyrics View (line + word-by-word) ──────────────────────────────── -class _SyncedLyricsView extends StatefulWidget { - final LyricsData lyrics; - final int positionMs; - final ColorScheme colorScheme; - final ValueChanged? onSeek; - - const _SyncedLyricsView({ - required this.lyrics, - required this.positionMs, - required this.colorScheme, - required this.onSeek, - }); - - @override - State<_SyncedLyricsView> createState() => _SyncedLyricsViewState(); -} - -class _SyncedLyricsViewState extends State<_SyncedLyricsView> { - final ScrollController _scrollController = ScrollController(); - final GlobalKey _currentLineKey = GlobalKey(); - int _lastScrolledLine = -1; - int _lastQueuedScrollLine = -1; - int? _pendingAutoScrollLine; - bool _userScrolling = false; - bool _isAutoScrolling = false; - Timer? _userScrollTimer; - double _viewHeight = 400; - - @override - void dispose() { - _scrollController.dispose(); - _userScrollTimer?.cancel(); - super.dispose(); - } - - int _findCurrentLineIndex() { - final pos = widget.positionMs; - final lines = widget.lyrics.lines; - if (lines.isEmpty) return -1; - - // Binary search: find the last line whose startMs <= current position. - var left = 0; - var right = lines.length - 1; - var result = -1; - while (left <= right) { - final mid = left + ((right - left) >> 1); - if (lines[mid].startMs <= pos) { - result = mid; - left = mid + 1; - } else { - right = mid - 1; - } - } - return result; - } - - double? _targetOffsetFromCurrentLineKey() { - if (!_scrollController.hasClients) return null; - final keyContext = _currentLineKey.currentContext; - if (keyContext == null) return null; - final renderObject = keyContext.findRenderObject(); - if (renderObject == null) return null; - final viewport = RenderAbstractViewport.of(renderObject); - final target = viewport.getOffsetToReveal(renderObject, 0.4).offset; - return target - .clamp(0.0, _scrollController.position.maxScrollExtent) - .toDouble(); - } - - Duration _autoScrollDuration(double distancePx) { - final clampedDistance = distancePx.clamp(80.0, 900.0); - var ms = (160 + (clampedDistance / 2.4)).round(); - if (ms < 180) ms = 180; - if (ms > 560) ms = 560; - return Duration(milliseconds: ms); - } - - Future _scrollToLine(int index) async { - if (_userScrolling || !_scrollController.hasClients) return; - if (_isAutoScrolling) { - _pendingAutoScrollLine = index; - return; - } - if (index == _lastScrolledLine) return; - _lastScrolledLine = index; - - double targetOffset; - final fromKey = _targetOffsetFromCurrentLineKey(); - if (fromKey != null) { - targetOffset = fromKey; - } else { - // Fallback: estimate-based scroll for off-screen items - const lineHeight = 44.0; - final topPad = _viewHeight * 0.4; - targetOffset = topPad + (index * lineHeight) - (_viewHeight * 0.4); - targetOffset = targetOffset - .clamp(0.0, _scrollController.position.maxScrollExtent) - .toDouble(); - } - - final distance = (targetOffset - _scrollController.offset).abs(); - if (distance < 1.0) return; - - _isAutoScrolling = true; - try { - await _scrollController.animateTo( - targetOffset, - duration: _autoScrollDuration(distance), - curve: Curves.easeInOutCubicEmphasized, - ); - } catch (_) { - // Ignore interrupted scroll animations; latest queued target will run next. - } finally { - _isAutoScrolling = false; - final pending = _pendingAutoScrollLine; - _pendingAutoScrollLine = null; - if (pending != null && pending != index && mounted) { - unawaited(_scrollToLine(pending)); - } - } - } - - @override - Widget build(BuildContext context) { - final currentLine = _findCurrentLineIndex(); - - // Auto-scroll only when the target line changes. - if (currentLine >= 0 && currentLine != _lastQueuedScrollLine) { - _lastQueuedScrollLine = currentLine; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(_scrollToLine(currentLine)); - } - }); - } - - return LayoutBuilder( - builder: (context, constraints) { - _viewHeight = constraints.maxHeight; - - return NotificationListener( - onNotification: (notification) { - if (notification is ScrollStartNotification && - notification.dragDetails != null) { - _userScrolling = true; - _userScrollTimer?.cancel(); - _pendingAutoScrollLine = null; - } - if (notification is ScrollEndNotification && _userScrolling) { - _userScrollTimer = Timer(const Duration(seconds: 4), () { - _userScrolling = false; - _isAutoScrolling = false; - _lastScrolledLine = -1; // Force re-scroll - _lastQueuedScrollLine = -1; - _pendingAutoScrollLine = null; - }); - } - return false; - }, - child: ListView.builder( - controller: _scrollController, - padding: EdgeInsets.only( - left: 24, - right: 24, - top: _viewHeight * 0.4, - bottom: _viewHeight * 0.4, - ), - itemCount: widget.lyrics.lines.length, - itemBuilder: (context, index) { - final line = widget.lyrics.lines[index]; - final isCurrent = index == currentLine; - final isPast = index < currentLine; - - Widget lineWidget; - - if (line.text.isEmpty) { - // Empty line = interlude gap - lineWidget = const SizedBox(height: 32); - } else { - // Target style — AnimatedDefaultTextStyle will - // smoothly tween fontSize / fontWeight / color. - final targetStyle = TextStyle( - fontSize: isCurrent ? 24 : 19, - fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w500, - color: isCurrent - ? widget.colorScheme.onSurface - : isPast - ? widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.35, - ) - : widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.55, - ), - height: 1.4, - ); - - lineWidget = GestureDetector( - onTap: widget.onSeek == null - ? null - : () => widget.onSeek!(line.startMs), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - style: targetStyle, - child: line.hasWordSync - ? _WordByWordLine( - line: line, - positionMs: widget.positionMs, - colorScheme: widget.colorScheme, - isCurrent: isCurrent, - ) - : Text(line.text), - ), - ), - ); - } - - // Attach key to the current line for scroll targeting. - if (isCurrent && line.text.isNotEmpty) { - return KeyedSubtree(key: _currentLineKey, child: lineWidget); - } - return lineWidget; - }, - ), - ); - }, - ); - } -} - -// ─── Word-by-Word Highlighted Line ─────────────────────────────────────────── -class _WordByWordLine extends StatelessWidget { - final LyricsLine line; - final int positionMs; - final ColorScheme colorScheme; - final bool isCurrent; - - const _WordByWordLine({ - required this.line, - required this.positionMs, - required this.colorScheme, - required this.isCurrent, - }); - - @override - Widget build(BuildContext context) { - // When not the current line, render plain text that inherits the - // animated style from the parent AnimatedDefaultTextStyle. - if (!isCurrent) { - return Text(line.text); - } - - // Current line: word-by-word gradient sweep - final baseStyle = TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.4, - ); - final inactiveColor = colorScheme.onSurfaceVariant.withValues(alpha: 0.35); - final sungColor = colorScheme.onSurface; - final activeColor = colorScheme.primary; - - return Wrap( - children: line.words.map((word) { - final isCurrentWord = - positionMs >= word.startMs && positionMs < word.endMs; - final isSung = positionMs >= word.endMs; - final wordProgress = isSung - ? 1.0 - : isCurrentWord && word.endMs > word.startMs - ? ((positionMs - word.startMs) / (word.endMs - word.startMs)).clamp( - 0.0, - 1.0, - ) - : 0.0; - - return _AnimatedWordToken( - text: word.text, - progress: wordProgress, - isCurrentWord: isCurrentWord, - baseStyle: baseStyle, - inactiveColor: inactiveColor, - sungColor: sungColor, - activeColor: activeColor, - ); - }).toList(), - ); - } -} - -class _AnimatedWordToken extends StatelessWidget { - final String text; - final double progress; - final bool isCurrentWord; - final TextStyle baseStyle; - final Color inactiveColor; - final Color sungColor; - final Color activeColor; - - const _AnimatedWordToken({ - required this.text, - required this.progress, - required this.isCurrentWord, - required this.baseStyle, - required this.inactiveColor, - required this.sungColor, - required this.activeColor, - }); - - @override - Widget build(BuildContext context) { - final p = progress.clamp(0.0, 1.0); - final hasSweep = p > 0.0 && p < 1.0; - final settledColor = p >= 1.0 ? sungColor : inactiveColor; - - return AnimatedScale( - scale: isCurrentWord ? 1.04 : 1.0, - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, - child: Stack( - children: [ - Text(text, style: baseStyle.copyWith(color: settledColor)), - if (hasSweep) - ClipRect( - child: Align( - alignment: Alignment.centerLeft, - widthFactor: p, - child: Text( - text, - style: baseStyle.copyWith(color: activeColor), - ), - ), - ), - ], - ), - ); - } -} - -// ─── Unsynced Lyrics View ──────────────────────────────────────────────────── -class _UnsyncedLyricsView extends StatelessWidget { - final LyricsData lyrics; - final ColorScheme colorScheme; - - const _UnsyncedLyricsView({required this.lyrics, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), - itemCount: lyrics.lines.length, - itemBuilder: (context, index) { - final line = lyrics.lines[index]; - if (line.text.isEmpty) return const SizedBox(height: 24); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - line.text, - style: TextStyle( - fontSize: 19, - fontWeight: FontWeight.w500, - color: colorScheme.onSurface.withValues(alpha: 0.8), - height: 1.5, - ), - ), - ); - }, - ); - } -} - -// ─── Quality + Service Row ─────────────────────────────────────────────────── -class _QualityServiceRow extends StatelessWidget { - final PlaybackItem item; - final ColorScheme colorScheme; - - const _QualityServiceRow({required this.item, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - final qualityLabel = item.qualityLabel; - final serviceLabel = _serviceDisplayName(item.service); - - if (qualityLabel.isEmpty && serviceLabel.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Wrap( - spacing: 8, - runSpacing: 4, - alignment: WrapAlignment.center, - children: [ - if (serviceLabel.isNotEmpty) - _Chip( - icon: Icons.cloud_outlined, - label: serviceLabel, - colorScheme: colorScheme, - ), - if (qualityLabel.isNotEmpty) - _Chip( - icon: Icons.graphic_eq_rounded, - label: qualityLabel, - colorScheme: colorScheme, - ), - ], - ), - ); - } - - String _serviceDisplayName(String service) { - if (service.isEmpty) return ''; - switch (service.toLowerCase()) { - case 'tidal': - return 'Tidal'; - case 'qobuz': - return 'Qobuz'; - case 'amazon': - return 'Amazon Music'; - case 'youtube': - return 'YouTube'; - case 'offline': - return 'Local file'; - default: - if (service.isNotEmpty) { - return service[0].toUpperCase() + service.substring(1); - } - return service; - } - } -} - -class _Chip extends StatelessWidget { - final IconData icon; - final String label; - final ColorScheme colorScheme; - - const _Chip({ - required this.icon, - required this.label, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: colorScheme.onPrimaryContainer), - const SizedBox(width: 4), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -// ─── Download Button ───────────────────────────────────────────────────────── -class _DownloadButton extends ConsumerWidget { - final PlaybackItem item; - final bool compact; - - const _DownloadButton({required this.item, this.compact = false}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final track = item.track; - if (track == null) return const SizedBox.shrink(); - - final colorScheme = Theme.of(context).colorScheme; - final iconSize = compact ? 18.0 : 22.0; - - return IconButton( - visualDensity: compact ? VisualDensity.compact : VisualDensity.standard, - icon: Icon( - Icons.download_rounded, - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - onPressed: () => _onDownloadTap(context, ref, track), - tooltip: context.l10n.downloadTitle, - ); - } - - void _onDownloadTap(BuildContext context, WidgetRef ref, Track track) { - final settings = ref.read(settingsProvider); - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedToQueue(track.name)), - ), - ); - }, - ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), - ); - } - } -} - -// ─── Playback Controls ─────────────────────────────────────────────────────── -class _PlaybackControls extends ConsumerWidget { - final PlaybackState state; - final bool compact; - - const _PlaybackControls({required this.state, this.compact = false}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final notifier = ref.read(playbackProvider.notifier); - final hasPrev = - state.hasPrevious || state.repeatMode == playback_types.RepeatMode.all; - final hasNext = - state.hasNext || state.repeatMode == playback_types.RepeatMode.all; - final sideIconSize = compact ? 18.0 : 22.0; - final skipIconSize = compact ? 28.0 : 32.0; - final mainButtonSize = compact ? 54.0 : 64.0; - final mainIconSize = compact ? 30.0 : 36.0; - final loadingSize = compact ? 24.0 : 28.0; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Shuffle - IconButton( - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - icon: Icon( - Icons.shuffle_rounded, - color: state.shuffle - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - size: sideIconSize, - ), - onPressed: notifier.toggleShuffle, - tooltip: 'Shuffle', - ), - SizedBox(width: compact ? 2 : 4), - - // Previous - IconButton( - iconSize: skipIconSize, - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - onPressed: hasPrev ? notifier.skipPrevious : null, - icon: Icon( - Icons.skip_previous_rounded, - color: hasPrev - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - ), - tooltip: 'Previous', - ), - SizedBox(width: compact ? 4 : 8), - - // Play / Pause (large) - SizedBox( - width: mainButtonSize, - height: mainButtonSize, - child: IconButton.filled( - iconSize: mainIconSize, - onPressed: notifier.togglePlayPause, - icon: state.isBuffering || state.isLoading - ? SizedBox( - width: loadingSize, - height: loadingSize, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: colorScheme.onPrimary, - ), - ) - : Icon( - state.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - style: IconButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - ), - tooltip: state.isPlaying ? 'Pause' : 'Play', - ), - ), - SizedBox(width: compact ? 4 : 8), - - // Next - IconButton( - iconSize: skipIconSize, - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - onPressed: hasNext ? notifier.skipNext : null, - icon: Icon( - Icons.skip_next_rounded, - color: hasNext - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - ), - tooltip: 'Next', - ), - SizedBox(width: compact ? 2 : 4), - - // Repeat - IconButton( - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - icon: Icon( - state.repeatMode == playback_types.RepeatMode.one - ? Icons.repeat_one_rounded - : Icons.repeat_rounded, - color: state.repeatMode != playback_types.RepeatMode.off - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - size: sideIconSize, - ), - onPressed: notifier.cycleRepeatMode, - tooltip: _repeatTooltip(state.repeatMode), - ), - ], - ); - } - - String _repeatTooltip(playback_types.RepeatMode mode) { - switch (mode) { - case playback_types.RepeatMode.off: - return 'Repeat: Off'; - case playback_types.RepeatMode.all: - return 'Repeat: All'; - case playback_types.RepeatMode.one: - return 'Repeat: One'; - } - } -} - -// ─── Cover Art Widget (supports both network and local) ────────────────────── -class _CoverArt extends StatelessWidget { - final String url; - final bool isLocal; - final double size; - final double borderRadius; - - const _CoverArt({ - required this.url, - required this.isLocal, - required this.size, - required this.borderRadius, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - if (url.trim().isEmpty) { - return _placeholder(colorScheme); - } - - if (isLocal) { - return ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: Image.file( - File(url), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: size.isFinite ? (size * 3).toInt() : null, - cacheHeight: size.isFinite ? (size * 3).toInt() : null, - errorBuilder: (_, _, _) => _placeholder(colorScheme), - ), - ); - } - - return ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: CachedNetworkImage( - imageUrl: url, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: size.isFinite ? (size * 3).toInt() : null, - memCacheHeight: size.isFinite ? (size * 3).toInt() : null, - cacheManager: CoverCacheManager.instance, - errorWidget: (_, _, _) => _placeholder(colorScheme), - ), - ); - } - - Widget _placeholder(ColorScheme colorScheme) { - final iconSize = size.isFinite ? size * 0.4 : 48.0; - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(borderRadius), - ), - child: Icon( - Icons.music_note_rounded, - size: iconSize, - color: colorScheme.onSurfaceVariant, - ), - ); - } -} - -// ─── Queue Bottom Sheet ────────────────────────────────────────────────────── -class _QueueBottomSheet extends ConsumerWidget { - final WidgetRef ref; - - const _QueueBottomSheet({required this.ref}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(playbackProvider); - final playbackNotifier = ref.read(playbackProvider.notifier); - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final queue = state.queue; - final displayOrder = playbackNotifier.getQueueDisplayOrder(); - final currentDisplayIndex = playbackNotifier.getCurrentDisplayQueuePosition( - displayOrder: displayOrder, - ); - if (queue.isEmpty || displayOrder.isEmpty || currentDisplayIndex < 0) { - return const SizedBox.shrink(); - } - - return DraggableScrollableSheet( - initialChildSize: 0.65, - minChildSize: 0.3, - maxChildSize: 0.92, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Drag handle - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Row( - children: [ - Icon( - Icons.queue_music_rounded, - size: 22, - color: colorScheme.primary, - ), - const SizedBox(width: 10), - Text( - 'Queue', - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - Text( - '${queue.length} tracks', - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - - const Divider(height: 1), - - // Queue list - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.only(bottom: 16), - itemCount: - queue.length + - _sectionHeaderCount(currentDisplayIndex, queue.length), - itemBuilder: (context, index) { - // Calculate real item index accounting for section headers - return _buildQueueListItem( - context, - ref, - index, - queue, - displayOrder, - currentDisplayIndex, - colorScheme, - textTheme, - ); - }, - ), - ), - ], - ); - }, - ); - } - - int _sectionHeaderCount(int currentIndex, int queueLength) { - int count = 0; - if (currentIndex > 0) count++; // "Already Played" header - count++; // "Now Playing" header - if (currentIndex < queueLength - 1) count++; // "Up Next" header - return count; - } - - Widget _buildQueueListItem( - BuildContext context, - WidgetRef ref, - int listIndex, - List queue, - List displayOrder, - int currentDisplayIndex, - ColorScheme colorScheme, - TextTheme textTheme, - ) { - // Build a flat list: [played header?, played items, now playing header, - // now playing item, up next header?, up next items] - int offset = 0; - - // Section: Already Played - if (currentDisplayIndex > 0) { - if (listIndex == offset) { - return _sectionHeader( - 'Played', - Icons.history_rounded, - colorScheme, - textTheme, - ); - } - offset++; - if (listIndex < offset + currentDisplayIndex) { - final displayIdx = listIndex - offset; - final queueIdx = displayOrder[displayIdx]; - return _queueTrackTile( - context, - ref, - queue[queueIdx], - queueIdx, - displayIdx, - colorScheme, - textTheme, - isPlayed: true, - ); - } - offset += currentDisplayIndex; - } - - // Section: Now Playing - if (listIndex == offset) { - return _sectionHeader( - 'Now Playing', - Icons.play_circle_filled_rounded, - colorScheme, - textTheme, - isPrimary: true, - ); - } - offset++; - if (listIndex == offset) { - final queueIdx = displayOrder[currentDisplayIndex]; - return _queueTrackTile( - context, - ref, - queue[queueIdx], - queueIdx, - currentDisplayIndex, - colorScheme, - textTheme, - isCurrent: true, - ); - } - offset++; - - // Section: Up Next - if (currentDisplayIndex < queue.length - 1) { - if (listIndex == offset) { - final upNextCount = queue.length - currentDisplayIndex - 1; - return _sectionHeader( - 'Up Next ($upNextCount)', - Icons.skip_next_rounded, - colorScheme, - textTheme, - ); - } - offset++; - final displayIdx = currentDisplayIndex + 1 + (listIndex - offset); - if (displayIdx < queue.length) { - final queueIdx = displayOrder[displayIdx]; - return _queueTrackTile( - context, - ref, - queue[queueIdx], - queueIdx, - displayIdx, - colorScheme, - textTheme, - ); - } - } - - return const SizedBox.shrink(); - } - - Widget _sectionHeader( - String title, - IconData icon, - ColorScheme colorScheme, - TextTheme textTheme, { - bool isPrimary = false, - }) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), - child: Row( - children: [ - Icon( - icon, - size: 16, - color: isPrimary - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - title, - style: textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - color: isPrimary - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - Widget _queueTrackTile( - BuildContext context, - WidgetRef ref, - PlaybackItem item, - int queueIndex, - int displayIndex, - ColorScheme colorScheme, - TextTheme textTheme, { - bool isCurrent = false, - bool isPlayed = false, - }) { - final opacity = isPlayed ? 0.5 : 1.0; - - return Material( - color: isCurrent - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : Colors.transparent, - child: InkWell( - onTap: isCurrent - ? null - : () { - ref.read(playbackProvider.notifier).playQueueIndex(queueIndex); - Navigator.of(context).pop(); - }, - child: Opacity( - opacity: opacity, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - // Track number in queue - SizedBox( - width: 28, - child: Text( - '${displayIndex + 1}', - textAlign: TextAlign.center, - style: textTheme.bodySmall?.copyWith( - color: isCurrent - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w400, - ), - ), - ), - const SizedBox(width: 8), - // Cover art - _CoverArt( - url: item.coverUrl, - isLocal: item.hasLocalCover, - size: 44, - borderRadius: 8, - ), - const SizedBox(width: 12), - // Track info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyMedium?.copyWith( - fontWeight: isCurrent - ? FontWeight.w700 - : FontWeight.w500, - color: isCurrent - ? colorScheme.primary - : colorScheme.onSurface, - ), - ), - const SizedBox(height: 2), - Text( - item.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // Now playing indicator - if (isCurrent) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Icon( - Icons.equalizer_rounded, - size: 20, - color: colorScheme.primary, - ), - ), - // Remove from queue button (for up next items only) - if (!isCurrent && !isPlayed) - IconButton( - icon: Icon( - Icons.close_rounded, - size: 18, - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ), - ), - onPressed: () { - ref - .read(playbackProvider.notifier) - .removeFromQueue(queueIndex); - }, - visualDensity: VisualDensity.compact, - tooltip: 'Remove', - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index c45649f1..d21a67fe 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -1,18 +1,10 @@ -import 'dart:async'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/utils/file_access.dart'; -import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -64,9 +56,6 @@ class _TrackOptionsSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final settings = ref.watch(settingsProvider); - final rootContext = Navigator.of(context, rootNavigator: true).context; - final container = ProviderScope.containerOf(rootContext, listen: false); final isLoved = ref.watch( libraryCollectionsProvider.select((state) => state.isLoved(track)), @@ -174,46 +163,6 @@ class _TrackOptionsSheet extends ConsumerWidget { ), // Action items (matches _QualityOption style) - _OptionTile( - icon: Icons.download_rounded, - title: 'Download & Play', - onTap: () async { - Navigator.pop(context); - final playedLocal = await _playLocalIfAvailable( - container, - rootContext, - ); - if (playedLocal) { - return; - } - if (!rootContext.mounted) { - return; - } - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - rootContext, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - onSelect: (quality, service) { - _enqueueDownloadAndAutoPlay( - container: container, - context: rootContext, - service: service, - quality: quality, - ); - }, - ); - } else { - _enqueueDownloadAndAutoPlay( - container: container, - context: rootContext, - service: settings.defaultService, - ); - } - }, - ), _OptionTile( icon: isLoved ? Icons.favorite : Icons.favorite_border, iconColor: isLoved ? colorScheme.error : null, @@ -282,138 +231,6 @@ class _TrackOptionsSheet extends ConsumerWidget { ), ); } - - Future _playLocalIfAvailable( - ProviderContainer container, - BuildContext context, - ) async { - final localState = container.read(localLibraryProvider); - final historyState = container.read(downloadHistoryProvider); - final historyNotifier = container.read(downloadHistoryProvider.notifier); - - try { - DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( - track.id, - ); - final isrc = track.isrc?.trim(); - historyItem ??= (isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) - : null; - historyItem ??= historyState.findByTrackAndArtist( - track.name, - track.artistName, - ); - - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - await container - .read(playbackProvider.notifier) - .playLocalPath( - path: historyItem.filePath, - title: track.name, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', - ); - return true; - } - historyNotifier.removeFromHistory(historyItem.id); - } - - var localItem = (isrc != null && isrc.isNotEmpty) - ? localState.getByIsrc(isrc) - : null; - localItem ??= localState.findByTrackAndArtist( - track.name, - track.artistName, - ); - - if (localItem != null && await fileExists(localItem.filePath)) { - await container - .read(playbackProvider.notifier) - .playLocalPath( - path: localItem.filePath, - title: localItem.trackName, - artist: localItem.artistName, - album: localItem.albumName, - coverUrl: localItem.coverPath ?? track.coverUrl ?? '', - ); - return true; - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), - ); - } - return true; - } - - return false; - } - - void _enqueueDownloadAndAutoPlay({ - required ProviderContainer container, - required BuildContext context, - required String service, - String? quality, - }) { - container - .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), - ); - } - unawaited(_waitForDownloadedFileAndPlay(container, context)); - } - - Future _waitForDownloadedFileAndPlay( - ProviderContainer container, - BuildContext context, - ) async { - const maxAttempts = 180; // up to ~3 minutes - for (var i = 0; i < maxAttempts; i++) { - final item = _findHistoryMatch(container); - if (item != null && await fileExists(item.filePath)) { - try { - await container - .read(playbackProvider.notifier) - .playLocalPath( - path: item.filePath, - title: track.name, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile('$e')), - ), - ); - } - } - return; - } - await Future.delayed(const Duration(seconds: 1)); - } - } - - DownloadHistoryItem? _findHistoryMatch(ProviderContainer container) { - final historyState = container.read(downloadHistoryProvider); - final historyNotifier = container.read(downloadHistoryProvider.notifier); - final isrc = track.isrc?.trim(); - - return historyNotifier.getBySpotifyId(track.id) ?? - ((isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) - : null) ?? - historyState.findByTrackAndArtist(track.name, track.artistName); - } } /// Styled like _QualityOption in download_service_picker.dart diff --git a/pubspec.lock b/pubspec.lock index 3492ac5c..967ee1bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,38 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" - audio_service: - dependency: "direct main" - description: - name: audio_service - sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 - url: "https://pub.dev" - source: hosted - version: "0.18.18" - audio_service_platform_interface: - dependency: transitive - description: - name: audio_service_platform_interface - sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" - url: "https://pub.dev" - source: hosted - version: "0.1.3" - audio_service_web: - dependency: transitive - description: - name: audio_service_web - sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df - url: "https://pub.dev" - source: hosted - version: "0.1.4" - audio_session: - dependency: "direct main" - description: - name: audio_session - sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" - url: "https://pub.dev" - source: hosted - version: "0.2.2" boolean_selector: dependency: transitive description: @@ -613,30 +581,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.2" - just_audio: - dependency: "direct main" - description: - name: just_audio - sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" - url: "https://pub.dev" - source: hosted - version: "0.10.5" - just_audio_platform_interface: - dependency: transitive - description: - name: just_audio_platform_interface - sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" - url: "https://pub.dev" - source: hosted - version: "4.6.0" - just_audio_web: - dependency: transitive - description: - name: just_audio_web - sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" - url: "https://pub.dev" - source: hosted - version: "0.4.16" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f6cefdfe..0e987f6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 4.0.1+102 +version: 3.7.0+103 environment: sdk: ^3.10.0 @@ -61,9 +61,6 @@ dependencies: # Notifications flutter_local_notifications: 20.0.0 - just_audio: ^0.10.5 - audio_session: ^0.2.2 - audio_service: ^0.18.17 dev_dependencies: flutter_test: From 2a0216c87a602b279642fd869e04026b00e69a26 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:14:36 +0000 Subject: [PATCH 33/38] fix(deps): update dependency flutter_local_notifications to v21 --- pubspec.lock | 54 ++++++++++++++++++++++------------------------------ pubspec.yaml | 2 +- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 967ee1bf..0370159a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -362,34 +362,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac" + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" url: "https://pub.dev" source: hosted - version: "20.0.0" + version: "21.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "8.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "11.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61" + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -557,14 +557,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -633,18 +625,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1166,34 +1158,34 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" timezone: dependency: transitive description: name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" url: "https://pub.dev" source: hosted - version: "0.10.1" + version: "0.11.0" typed_data: dependency: transitive description: @@ -1372,4 +1364,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.0 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 0e987f6b..e109209f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,7 +60,7 @@ dependencies: open_filex: ^4.7.0 # Notifications - flutter_local_notifications: 20.0.0 + flutter_local_notifications: 21.0.0 dev_dependencies: flutter_test: From 3a7b77771702fd0cd6a0e62aa090519349f6e4f0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 5 Mar 2026 00:02:49 +0700 Subject: [PATCH 34/38] fix(queue): unique queue IDs, nullable currentDownload, local cancel tracking; refactor(l10n): consolidate and clean up localization files download_queue_provider: generate unique queue item IDs with sequence counter to prevent collisions, fix copyWith to allow setting currentDownload to null via sentinel object pattern, add _locallyCancelledItemIds set for reliable cancel state, normalize restored queue IDs on load. l10n: remove redundant keys, consolidate ARB files, regenerate Dart localization classes. --- lib/l10n/app_localizations.dart | 1614 +---- lib/l10n/app_localizations_de.dart | 943 +-- lib/l10n/app_localizations_en.dart | 938 +-- lib/l10n/app_localizations_es.dart | 1823 +----- lib/l10n/app_localizations_fr.dart | 943 +-- lib/l10n/app_localizations_hi.dart | 939 +-- lib/l10n/app_localizations_id.dart | 946 +-- lib/l10n/app_localizations_ja.dart | 931 +-- lib/l10n/app_localizations_ko.dart | 932 +-- lib/l10n/app_localizations_nl.dart | 939 +-- lib/l10n/app_localizations_pt.dart | 1820 +----- lib/l10n/app_localizations_ru.dart | 987 +-- lib/l10n/app_localizations_tr.dart | 941 +-- lib/l10n/app_localizations_zh.dart | 2667 +------- lib/l10n/arb/app_de.arb | 1116 +--- lib/l10n/arb/app_en.arb | 3688 ++++++----- lib/l10n/arb/app_es.arb | 872 +-- lib/l10n/arb/app_es_ES.arb | 1116 +--- lib/l10n/arb/app_fr.arb | 1116 +--- lib/l10n/arb/app_hi.arb | 1116 +--- lib/l10n/arb/app_id.arb | 6745 ++++++++------------ lib/l10n/arb/app_ja.arb | 1116 +--- lib/l10n/arb/app_ko.arb | 1116 +--- lib/l10n/arb/app_nl.arb | 1116 +--- lib/l10n/arb/app_pt.arb | 872 +-- lib/l10n/arb/app_pt_PT.arb | 1116 +--- lib/l10n/arb/app_ru.arb | 1116 +--- lib/l10n/arb/app_tr.arb | 1116 +--- lib/l10n/arb/app_zh.arb | 872 +-- lib/l10n/arb/app_zh_CN.arb | 1116 +--- lib/l10n/arb/app_zh_TW.arb | 1116 +--- lib/providers/download_queue_provider.dart | 172 +- 32 files changed, 5457 insertions(+), 38519 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 85962c2a..b321656f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -130,12 +130,6 @@ abstract class AppLocalizations { /// **'SpotiFLAC'** String get appName; - /// App description shown in about page - /// - /// In en, this message translates to: - /// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'** - String get appDescription; - /// Bottom navigation - Home tab /// /// In en, this message translates to: @@ -148,12 +142,6 @@ abstract class AppLocalizations { /// **'Library'** String get navLibrary; - /// Bottom navigation - History tab (legacy) - /// - /// In en, this message translates to: - /// **'History'** - String get navHistory; - /// Bottom navigation - Settings tab /// /// In en, this message translates to: @@ -172,18 +160,6 @@ abstract class AppLocalizations { /// **'Home'** String get homeTitle; - /// Placeholder text in search box - /// - /// In en, this message translates to: - /// **'Paste Spotify URL or search...'** - String get homeSearchHint; - - /// Placeholder when extension search is active - /// - /// In en, this message translates to: - /// **'Search with {extensionName}...'** - String homeSearchHintExtension(String extensionName); - /// Subtitle shown below search box /// /// In en, this message translates to: @@ -202,24 +178,6 @@ abstract class AppLocalizations { /// **'Recent'** String get homeRecent; - /// History screen title - /// - /// In en, this message translates to: - /// **'History'** - String get historyTitle; - - /// Tab showing active downloads count - /// - /// In en, this message translates to: - /// **'Downloading ({count})'** - String historyDownloading(int count); - - /// Tab showing completed downloads - /// - /// In en, this message translates to: - /// **'Downloaded'** - String get historyDownloaded; - /// Filter chip - show all items /// /// In en, this message translates to: @@ -238,54 +196,6 @@ abstract class AppLocalizations { /// **'Singles'** String get historyFilterSingles; - /// Track count with plural form - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 track} other{{count} tracks}}'** - String historyTracksCount(int count); - - /// Album count with plural form - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 album} other{{count} albums}}'** - String historyAlbumsCount(int count); - - /// Empty state title - /// - /// In en, this message translates to: - /// **'No download history'** - String get historyNoDownloads; - - /// Empty state subtitle - /// - /// In en, this message translates to: - /// **'Downloaded tracks will appear here'** - String get historyNoDownloadsSubtitle; - - /// Empty state when filtering albums - /// - /// In en, this message translates to: - /// **'No album downloads'** - String get historyNoAlbums; - - /// Empty state subtitle for albums filter - /// - /// In en, this message translates to: - /// **'Download multiple tracks from an album to see them here'** - String get historyNoAlbumsSubtitle; - - /// Empty state when filtering singles - /// - /// In en, this message translates to: - /// **'No single downloads'** - String get historyNoSingles; - - /// Empty state subtitle for singles filter - /// - /// In en, this message translates to: - /// **'Single track downloads will appear here'** - String get historyNoSinglesSubtitle; - /// Search bar placeholder in history /// /// In en, this message translates to: @@ -334,48 +244,6 @@ abstract class AppLocalizations { /// **'Download'** String get downloadTitle; - /// Setting for download folder - /// - /// In en, this message translates to: - /// **'Download Location'** - String get downloadLocation; - - /// Subtitle for download location - /// - /// In en, this message translates to: - /// **'Choose where to save files'** - String get downloadLocationSubtitle; - - /// Shown when using default folder - /// - /// In en, this message translates to: - /// **'Default location'** - String get downloadLocationDefault; - - /// Setting for preferred download service (Tidal/Qobuz/Amazon) - /// - /// In en, this message translates to: - /// **'Default Service'** - String get downloadDefaultService; - - /// Subtitle for default service - /// - /// In en, this message translates to: - /// **'Service used for downloads'** - String get downloadDefaultServiceSubtitle; - - /// Setting for audio quality - /// - /// In en, this message translates to: - /// **'Default Quality'** - String get downloadDefaultQuality; - - /// Toggle to show quality picker - /// - /// In en, this message translates to: - /// **'Ask Quality Before Download'** - String get downloadAskQuality; - /// Subtitle for ask quality toggle /// /// In en, this message translates to: @@ -394,54 +262,12 @@ abstract class AppLocalizations { /// **'Folder Organization'** String get downloadFolderOrganization; - /// Toggle to separate single tracks - /// - /// In en, this message translates to: - /// **'Separate Singles'** - String get downloadSeparateSingles; - - /// Subtitle for separate singles toggle - /// - /// In en, this message translates to: - /// **'Put single tracks in a separate folder'** - String get downloadSeparateSinglesSubtitle; - - /// Audio quality option - highest available - /// - /// In en, this message translates to: - /// **'Best Available'** - String get qualityBest; - - /// Audio quality option - FLAC lossless - /// - /// In en, this message translates to: - /// **'FLAC'** - String get qualityFlac; - - /// Audio quality option - 320kbps MP3 - /// - /// In en, this message translates to: - /// **'320 kbps'** - String get quality320; - - /// Audio quality option - 128kbps MP3 - /// - /// In en, this message translates to: - /// **'128 kbps'** - String get quality128; - /// Appearance settings page title /// /// In en, this message translates to: /// **'Appearance'** String get appearanceTitle; - /// Theme mode setting - /// - /// In en, this message translates to: - /// **'Theme'** - String get appearanceTheme; - /// Follow system theme /// /// In en, this message translates to: @@ -472,12 +298,6 @@ abstract class AppLocalizations { /// **'Use colors from your wallpaper'** String get appearanceDynamicColorSubtitle; - /// Custom accent color picker - /// - /// In en, this message translates to: - /// **'Accent Color'** - String get appearanceAccentColor; - /// Layout style for history /// /// In en, this message translates to: @@ -502,12 +322,6 @@ abstract class AppLocalizations { /// **'Options'** String get optionsTitle; - /// Section for search provider settings - /// - /// In en, this message translates to: - /// **'Search Source'** - String get optionsSearchSource; - /// Main search provider setting /// /// In en, this message translates to: @@ -544,54 +358,6 @@ abstract class AppLocalizations { /// **'Try other services if download fails'** String get optionsAutoFallbackSubtitle; - /// Toggle to skip to the next queue track when current track stream resolution fails - /// - /// In en, this message translates to: - /// **'Auto Skip Unavailable Tracks'** - String get optionsAutoSkipUnavailableTracks; - - /// Subtitle when auto skip on resolve failure is enabled - /// - /// In en, this message translates to: - /// **'Automatically skip to the next queue track when a stream cannot be resolved.'** - String get optionsAutoSkipUnavailableTracksSubtitleOn; - - /// Subtitle when auto skip on resolve failure is disabled - /// - /// In en, this message translates to: - /// **'Stop on failed track resolution and show an error.'** - String get optionsAutoSkipUnavailableTracksSubtitleOff; - - /// Tap behavior mode for track lists - /// - /// In en, this message translates to: - /// **'Interaction Mode'** - String get optionsInteractionMode; - - /// Interaction mode where taps queue downloads - /// - /// In en, this message translates to: - /// **'Downloader Mode'** - String get modeDownloader; - - /// Subtitle for downloader interaction mode - /// - /// In en, this message translates to: - /// **'Tap tracks to add them to download queue'** - String get modeDownloaderSubtitle; - - /// Interaction mode where taps start playback - /// - /// In en, this message translates to: - /// **'Streaming Mode'** - String get modeStreaming; - - /// Subtitle for streaming interaction mode - /// - /// In en, this message translates to: - /// **'Tap tracks to play instantly'** - String get modeStreamingSubtitle; - /// Enable extension download providers /// /// In en, this message translates to: @@ -772,30 +538,6 @@ abstract class AppLocalizations { /// **'Extensions'** String get extensionsTitle; - /// Section header for installed extensions - /// - /// In en, this message translates to: - /// **'Installed Extensions'** - String get extensionsInstalled; - - /// Empty state title - /// - /// In en, this message translates to: - /// **'No extensions installed'** - String get extensionsNone; - - /// Empty state subtitle - /// - /// In en, this message translates to: - /// **'Install extensions from the Store tab'** - String get extensionsNoneSubtitle; - - /// Extension status - active - /// - /// In en, this message translates to: - /// **'Enabled'** - String get extensionsEnabled; - /// Extension status - inactive /// /// In en, this message translates to: @@ -820,12 +562,6 @@ abstract class AppLocalizations { /// **'Uninstall'** String get extensionsUninstall; - /// Use extension for search - /// - /// In en, this message translates to: - /// **'Set as Search Provider'** - String get extensionsSetAsSearch; - /// Store screen title /// /// In en, this message translates to: @@ -970,12 +706,6 @@ abstract class AppLocalizations { /// **'Social'** String get aboutSocial; - /// Section for support/donation links - /// - /// In en, this message translates to: - /// **'Support'** - String get aboutSupport; - /// Section for app info /// /// In en, this message translates to: @@ -1006,18 +736,6 @@ abstract class AppLocalizations { /// **'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'** String get aboutSjdonadoDesc; - /// Name of Amazon API service - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'DoubleDouble'** - String get aboutDoubleDouble; - - /// Credit for DoubleDouble API - /// - /// In en, this message translates to: - /// **'Amazing API for Amazon Music downloads. Thank you for making it free!'** - String get aboutDoubleDoubleDesc; - /// Name of Qobuz API service - DO NOT TRANSLATE /// /// In en, this message translates to: @@ -1048,42 +766,6 @@ abstract class AppLocalizations { /// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'** String get aboutAppDescription; - /// Album screen title - /// - /// In en, this message translates to: - /// **'Album'** - String get albumTitle; - - /// Album track count - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 track} other{{count} tracks}}'** - String albumTracks(int count); - - /// Button to download all tracks - /// - /// In en, this message translates to: - /// **'Download All'** - String get albumDownloadAll; - - /// Button to download remaining tracks - /// - /// In en, this message translates to: - /// **'Download Remaining'** - String get albumDownloadRemaining; - - /// Playlist screen title - /// - /// In en, this message translates to: - /// **'Playlist'** - String get playlistTitle; - - /// Artist screen title - /// - /// In en, this message translates to: - /// **'Artist'** - String get artistTitle; - /// Section header for artist albums /// /// In en, this message translates to: @@ -1102,12 +784,6 @@ abstract class AppLocalizations { /// **'Compilations'** String get artistCompilations; - /// Artist release count - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 release} other{{count} releases}}'** - String artistReleases(int count); - /// Section header for popular/top tracks /// /// In en, this message translates to: @@ -1120,48 +796,6 @@ abstract class AppLocalizations { /// **'{count} monthly listeners'** String artistMonthlyListeners(String count); - /// Track metadata screen title - /// - /// In en, this message translates to: - /// **'Track Info'** - String get trackMetadataTitle; - - /// Metadata field - artist name - /// - /// In en, this message translates to: - /// **'Artist'** - String get trackMetadataArtist; - - /// Metadata field - album name - /// - /// In en, this message translates to: - /// **'Album'** - String get trackMetadataAlbum; - - /// Metadata field - track length - /// - /// In en, this message translates to: - /// **'Duration'** - String get trackMetadataDuration; - - /// Metadata field - audio quality - /// - /// In en, this message translates to: - /// **'Quality'** - String get trackMetadataQuality; - - /// Metadata field - file location - /// - /// In en, this message translates to: - /// **'File Path'** - String get trackMetadataPath; - - /// Metadata field - download date - /// - /// In en, this message translates to: - /// **'Downloaded'** - String get trackMetadataDownloadedAt; - /// Metadata field - download service used /// /// In en, this message translates to: @@ -1186,78 +820,12 @@ abstract class AppLocalizations { /// **'Delete'** String get trackMetadataDelete; - /// Action button - download again - /// - /// In en, this message translates to: - /// **'Re-download'** - String get trackMetadataRedownload; - - /// Action button - open containing folder - /// - /// In en, this message translates to: - /// **'Open Folder'** - String get trackMetadataOpenFolder; - - /// Setup wizard title - /// - /// In en, this message translates to: - /// **'Welcome to SpotiFLAC'** - String get setupTitle; - - /// Setup wizard subtitle - /// - /// In en, this message translates to: - /// **'Let\'s get you started'** - String get setupSubtitle; - - /// Storage permission step title - /// - /// In en, this message translates to: - /// **'Storage Permission'** - String get setupStoragePermission; - - /// Explanation for storage permission - /// - /// In en, this message translates to: - /// **'Required to save downloaded files'** - String get setupStoragePermissionSubtitle; - - /// Status when permission granted - /// - /// In en, this message translates to: - /// **'Permission granted'** - String get setupStoragePermissionGranted; - - /// Status when permission denied - /// - /// In en, this message translates to: - /// **'Permission denied'** - String get setupStoragePermissionDenied; - /// Button to request permission /// /// In en, this message translates to: /// **'Grant Permission'** String get setupGrantPermission; - /// Download folder step title - /// - /// In en, this message translates to: - /// **'Download Location'** - String get setupDownloadLocation; - - /// Button to pick folder - /// - /// In en, this message translates to: - /// **'Choose Folder'** - String get setupChooseFolder; - - /// Continue to next step button - /// - /// In en, this message translates to: - /// **'Continue'** - String get setupContinue; - /// Skip current step button /// /// In en, this message translates to: @@ -1270,12 +838,6 @@ abstract class AppLocalizations { /// **'Storage Access Required'** String get setupStorageAccessRequired; - /// Explanation for storage access - /// - /// In en, this message translates to: - /// **'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'** - String get setupStorageAccessMessage; - /// Android 11+ specific explanation /// /// In en, this message translates to: @@ -1306,12 +868,6 @@ abstract class AppLocalizations { /// **'{permissionType} permission is required for the best experience. You can change this later in Settings.'** String setupPermissionRequiredMessage(String permissionType); - /// Folder selection step title - /// - /// In en, this message translates to: - /// **'Select Download Folder'** - String get setupSelectDownloadFolder; - /// Dialog title for default folder /// /// In en, this message translates to: @@ -1384,36 +940,6 @@ abstract class AppLocalizations { /// **'Download Spotify tracks in FLAC'** String get setupDownloadInFlac; - /// Setup step indicator - storage - /// - /// In en, this message translates to: - /// **'Storage'** - String get setupStepStorage; - - /// Setup step indicator - notification - /// - /// In en, this message translates to: - /// **'Notification'** - String get setupStepNotification; - - /// Setup step indicator - folder - /// - /// In en, this message translates to: - /// **'Folder'** - String get setupStepFolder; - - /// Setup step indicator - Spotify API - /// - /// In en, this message translates to: - /// **'Spotify'** - String get setupStepSpotify; - - /// Setup step indicator - permission - /// - /// In en, this message translates to: - /// **'Permission'** - String get setupStepPermission; - /// Success message for storage permission /// /// In en, this message translates to: @@ -1444,18 +970,6 @@ abstract class AppLocalizations { /// **'Enable Notifications'** String get setupNotificationEnable; - /// Explanation for notifications - /// - /// In en, this message translates to: - /// **'Get notified when downloads complete or require attention.'** - String get setupNotificationDescription; - - /// Success message for folder selection - /// - /// In en, this message translates to: - /// **'Download Folder Selected!'** - String get setupFolderSelected; - /// Button to choose folder /// /// In en, this message translates to: @@ -1468,84 +982,18 @@ abstract class AppLocalizations { /// **'Select a folder where your downloaded music will be saved.'** String get setupFolderDescription; - /// Button to change selected folder - /// - /// In en, this message translates to: - /// **'Change Folder'** - String get setupChangeFolder; - /// Button to select folder /// /// In en, this message translates to: /// **'Select Folder'** String get setupSelectFolder; - /// Spotify API step title - /// - /// In en, this message translates to: - /// **'Spotify API (Optional)'** - String get setupSpotifyApiOptional; - - /// Explanation for Spotify API - /// - /// In en, this message translates to: - /// **'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'** - String get setupSpotifyApiDescription; - - /// Toggle to enable Spotify API - /// - /// In en, this message translates to: - /// **'Use Spotify API'** - String get setupUseSpotifyApi; - - /// Prompt to enter credentials - /// - /// In en, this message translates to: - /// **'Enter your credentials below'** - String get setupEnterCredentialsBelow; - - /// Status when using Deezer - /// - /// In en, this message translates to: - /// **'Using Deezer (no account needed)'** - String get setupUsingDeezer; - - /// Placeholder for client ID field - /// - /// In en, this message translates to: - /// **'Enter Spotify Client ID'** - String get setupEnterClientId; - - /// Placeholder for client secret field - /// - /// In en, this message translates to: - /// **'Enter Spotify Client Secret'** - String get setupEnterClientSecret; - - /// Info about getting Spotify credentials - /// - /// In en, this message translates to: - /// **'Get your free API credentials from the Spotify Developer Dashboard.'** - String get setupGetFreeCredentials; - /// Button to enable notifications /// /// In en, this message translates to: /// **'Enable Notifications'** String get setupEnableNotifications; - /// Message after completing a step - /// - /// In en, this message translates to: - /// **'You can now proceed to the next step.'** - String get setupProceedToNextStep; - - /// Info about notification usage - /// - /// In en, this message translates to: - /// **'You will receive download progress notifications.'** - String get setupNotificationProgressDescription; - /// Detailed notification explanation /// /// In en, this message translates to: @@ -1558,12 +1006,6 @@ abstract class AppLocalizations { /// **'Skip for now'** String get setupSkipForNow; - /// Back button text - /// - /// In en, this message translates to: - /// **'Back'** - String get setupBack; - /// Next button text /// /// In en, this message translates to: @@ -1576,36 +1018,18 @@ abstract class AppLocalizations { /// **'Get Started'** String get setupGetStarted; - /// Skip setup and start app - /// - /// In en, this message translates to: - /// **'Skip & Start'** - String get setupSkipAndStart; - /// Instruction for file access permission /// /// In en, this message translates to: /// **'Please enable \"Allow access to manage all files\" in the next screen.'** String get setupAllowAccessToManageFiles; - /// Link text for Spotify developer portal - /// - /// In en, this message translates to: - /// **'Get credentials from developer.spotify.com'** - String get setupGetCredentialsFromSpotify; - /// Dialog button - cancel action /// /// In en, this message translates to: /// **'Cancel'** String get dialogCancel; - /// Dialog button - confirm/acknowledge - /// - /// In en, this message translates to: - /// **'OK'** - String get dialogOk; - /// Dialog button - save changes /// /// In en, this message translates to: @@ -1624,36 +1048,12 @@ abstract class AppLocalizations { /// **'Retry'** String get dialogRetry; - /// Dialog button - close dialog - /// - /// In en, this message translates to: - /// **'Close'** - String get dialogClose; - - /// Dialog button - confirm yes - /// - /// In en, this message translates to: - /// **'Yes'** - String get dialogYes; - - /// Dialog button - confirm no - /// - /// In en, this message translates to: - /// **'No'** - String get dialogNo; - /// Dialog button - clear items /// /// In en, this message translates to: /// **'Clear'** String get dialogClear; - /// Dialog button - confirm action - /// - /// In en, this message translates to: - /// **'Confirm'** - String get dialogConfirm; - /// Dialog button - action completed /// /// In en, this message translates to: @@ -1696,48 +1096,12 @@ abstract class AppLocalizations { /// **'You have unsaved changes. Do you want to discard them?'** String get dialogUnsavedChanges; - /// Dialog title - download error - /// - /// In en, this message translates to: - /// **'Download Failed'** - String get dialogDownloadFailed; - - /// Label for track name in error dialog - /// - /// In en, this message translates to: - /// **'Track:'** - String get dialogTrackLabel; - - /// Label for artist name in error dialog - /// - /// In en, this message translates to: - /// **'Artist:'** - String get dialogArtistLabel; - - /// Label for error message - /// - /// In en, this message translates to: - /// **'Error:'** - String get dialogErrorLabel; - /// Dialog title - clear all items /// /// In en, this message translates to: /// **'Clear All'** String get dialogClearAll; - /// Dialog message - clear downloads confirmation - /// - /// In en, this message translates to: - /// **'Are you sure you want to clear all downloads?'** - String get dialogClearAllDownloads; - - /// Dialog title - delete file confirmation - /// - /// In en, this message translates to: - /// **'Remove from device?'** - String get dialogRemoveFromDevice; - /// Dialog title - uninstall extension /// /// In en, this message translates to: @@ -1870,12 +1234,6 @@ abstract class AppLocalizations { /// **'View Queue'** String get snackbarViewQueue; - /// Snackbar - loading error - /// - /// In en, this message translates to: - /// **'Failed to load: {error}'** - String snackbarFailedToLoad(String error); - /// Snackbar - URL copied /// /// In en, this message translates to: @@ -1942,72 +1300,18 @@ abstract class AppLocalizations { /// **'Too many requests. Please wait a moment before searching again.'** String get errorRateLimitedMessage; - /// Error message - loading failed - /// - /// In en, this message translates to: - /// **'Failed to load {item}'** - String errorFailedToLoad(String item); - /// Error - search returned no results /// /// In en, this message translates to: /// **'No tracks found'** String get errorNoTracksFound; - /// Error - seek disabled for live decrypted stream - /// - /// In en, this message translates to: - /// **'Seeking is not supported for this live stream'** - String get errorSeekNotSupported; - /// Error - extension source not available /// /// In en, this message translates to: /// **'Cannot load {item}: missing extension source'** String errorMissingExtensionSource(String item); - /// Download status - waiting in queue - /// - /// In en, this message translates to: - /// **'Queued'** - String get statusQueued; - - /// Download status - in progress - /// - /// In en, this message translates to: - /// **'Downloading'** - String get statusDownloading; - - /// Download status - writing metadata - /// - /// In en, this message translates to: - /// **'Finalizing'** - String get statusFinalizing; - - /// Download status - finished - /// - /// In en, this message translates to: - /// **'Completed'** - String get statusCompleted; - - /// Download status - error occurred - /// - /// In en, this message translates to: - /// **'Failed'** - String get statusFailed; - - /// Download status - already exists - /// - /// In en, this message translates to: - /// **'Skipped'** - String get statusSkipped; - - /// Download status - paused - /// - /// In en, this message translates to: - /// **'Paused'** - String get statusPaused; - /// Action button - pause download /// /// In en, this message translates to: @@ -2026,18 +1330,6 @@ abstract class AppLocalizations { /// **'Cancel'** String get actionCancel; - /// Action button - stop operation - /// - /// In en, this message translates to: - /// **'Stop'** - String get actionStop; - - /// Action button - enter selection mode - /// - /// In en, this message translates to: - /// **'Select'** - String get actionSelect; - /// Action button - select all items /// /// In en, this message translates to: @@ -2050,18 +1342,6 @@ abstract class AppLocalizations { /// **'Deselect'** String get actionDeselect; - /// Action button - paste from clipboard - /// - /// In en, this message translates to: - /// **'Paste'** - String get actionPaste; - - /// Action button - import CSV file - /// - /// In en, this message translates to: - /// **'Import CSV'** - String get actionImportCsv; - /// Action button - delete Spotify credentials /// /// In en, this message translates to: @@ -2086,18 +1366,6 @@ abstract class AppLocalizations { /// **'All tracks selected'** String get selectionAllSelected; - /// Hint - how to select items - /// - /// In en, this message translates to: - /// **'Tap tracks to select'** - String get selectionTapToSelect; - - /// Delete button with count - /// - /// In en, this message translates to: - /// **'Delete {count} {count, plural, =1{track} other{tracks}}'** - String selectionDeleteTracks(int count); - /// Placeholder when nothing selected /// /// In en, this message translates to: @@ -2146,66 +1414,12 @@ abstract class AppLocalizations { /// **'Play'** String get tooltipPlay; - /// Tooltip - cancel button - /// - /// In en, this message translates to: - /// **'Cancel'** - String get tooltipCancel; - - /// Tooltip - stop button - /// - /// In en, this message translates to: - /// **'Stop'** - String get tooltipStop; - - /// Tooltip - retry button - /// - /// In en, this message translates to: - /// **'Retry'** - String get tooltipRetry; - - /// Tooltip - remove button - /// - /// In en, this message translates to: - /// **'Remove'** - String get tooltipRemove; - - /// Tooltip - clear button - /// - /// In en, this message translates to: - /// **'Clear'** - String get tooltipClear; - - /// Tooltip - paste button - /// - /// In en, this message translates to: - /// **'Paste'** - String get tooltipPaste; - /// Setting title - filename pattern /// /// In en, this message translates to: /// **'Filename Format'** String get filenameFormat; - /// Preview of filename pattern - /// - /// In en, this message translates to: - /// **'Preview: {preview}'** - String filenameFormatPreview(String preview); - - /// Label for placeholder list - /// - /// In en, this message translates to: - /// **'Available placeholders:'** - String get filenameAvailablePlaceholders; - - /// Default filename format hint - /// - /// In en, this message translates to: - /// **'{artist} - {title}'** - String filenameHint(Object artist, Object title); - /// Toggle label for showing advanced filename tags /// /// In en, this message translates to: @@ -2218,12 +1432,6 @@ abstract class AppLocalizations { /// **'Enable formatted tags for track padding and date patterns'** String get filenameShowAdvancedTagsDescription; - /// Setting title - folder structure - /// - /// In en, this message translates to: - /// **'Folder Organization'** - String get folderOrganization; - /// Folder option - flat structure /// /// In en, this message translates to: @@ -2284,30 +1492,12 @@ abstract class AppLocalizations { /// **'Update Available'** String get updateAvailable; - /// Update available message - /// - /// In en, this message translates to: - /// **'Version {version} is available'** - String updateNewVersion(String version); - - /// Update button - download update - /// - /// In en, this message translates to: - /// **'Download'** - String get updateDownload; - /// Update button - dismiss /// /// In en, this message translates to: /// **'Later'** String get updateLater; - /// Link to changelog - /// - /// In en, this message translates to: - /// **'Changelog'** - String get updateChangelog; - /// Update status - initializing /// /// In en, this message translates to: @@ -2368,18 +1558,6 @@ abstract class AppLocalizations { /// **'Don\'t remind'** String get updateDontRemind; - /// Setting title - download provider order - /// - /// In en, this message translates to: - /// **'Provider Priority'** - String get providerPriority; - - /// Subtitle for provider priority - /// - /// In en, this message translates to: - /// **'Drag to reorder download providers'** - String get providerPrioritySubtitle; - /// Provider priority page title /// /// In en, this message translates to: @@ -2410,18 +1588,6 @@ abstract class AppLocalizations { /// **'Extension'** String get providerExtension; - /// Setting title - metadata provider order - /// - /// In en, this message translates to: - /// **'Metadata Provider Priority'** - String get metadataProviderPriority; - - /// Subtitle for metadata priority - /// - /// In en, this message translates to: - /// **'Order used when fetching track metadata'** - String get metadataProviderPrioritySubtitle; - /// Metadata priority page title /// /// In en, this message translates to: @@ -2458,30 +1624,6 @@ abstract class AppLocalizations { /// **'Logs'** String get logTitle; - /// Action - copy logs to clipboard - /// - /// In en, this message translates to: - /// **'Copy Logs'** - String get logCopy; - - /// Action - delete all logs - /// - /// In en, this message translates to: - /// **'Clear Logs'** - String get logClear; - - /// Action - share logs file - /// - /// In en, this message translates to: - /// **'Share Logs'** - String get logShare; - - /// Empty state title - /// - /// In en, this message translates to: - /// **'No logs yet'** - String get logEmpty; - /// Snackbar - logs copied /// /// In en, this message translates to: @@ -2530,30 +1672,6 @@ abstract class AppLocalizations { /// **'Are you sure you want to clear all logs?'** String get logClearLogsMessage; - /// Error category - ISP blocking - /// - /// In en, this message translates to: - /// **'ISP BLOCKING DETECTED'** - String get logIspBlocking; - - /// Error category - rate limiting - /// - /// In en, this message translates to: - /// **'RATE LIMITED'** - String get logRateLimited; - - /// Error category - network issues - /// - /// In en, this message translates to: - /// **'NETWORK ERROR'** - String get logNetworkError; - - /// Error category - missing tracks - /// - /// In en, this message translates to: - /// **'TRACK NOT FOUND'** - String get logTrackNotFound; - /// Filter dialog title /// /// In en, this message translates to: @@ -2572,72 +1690,6 @@ abstract class AppLocalizations { /// **'Logs will appear here as you use the app'** String get logNoLogsYetSubtitle; - /// Section header for error summary - /// - /// In en, this message translates to: - /// **'Issue Summary'** - String get logIssueSummary; - - /// ISP blocking explanation - /// - /// In en, this message translates to: - /// **'Your ISP may be blocking access to download services'** - String get logIspBlockingDescription; - - /// ISP blocking fix suggestion - /// - /// In en, this message translates to: - /// **'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'** - String get logIspBlockingSuggestion; - - /// Rate limit explanation - /// - /// In en, this message translates to: - /// **'Too many requests to the service'** - String get logRateLimitedDescription; - - /// Rate limit fix suggestion - /// - /// In en, this message translates to: - /// **'Wait a few minutes before trying again'** - String get logRateLimitedSuggestion; - - /// Network error explanation - /// - /// In en, this message translates to: - /// **'Connection issues detected'** - String get logNetworkErrorDescription; - - /// Network error fix suggestion - /// - /// In en, this message translates to: - /// **'Check your internet connection'** - String get logNetworkErrorSuggestion; - - /// Track not found explanation - /// - /// In en, this message translates to: - /// **'Some tracks could not be found on download services'** - String get logTrackNotFoundDescription; - - /// Track not found explanation - /// - /// In en, this message translates to: - /// **'The track may not be available in lossless quality'** - String get logTrackNotFoundSuggestion; - - /// Error count display - /// - /// In en, this message translates to: - /// **'Total errors: {count}'** - String logTotalErrors(int count); - - /// Affected domains display - /// - /// In en, this message translates to: - /// **'Affected: {domains}'** - String logAffected(String domains); - /// Log count with filter active /// /// In en, this message translates to: @@ -2836,12 +1888,6 @@ abstract class AppLocalizations { /// **'App Language'** String get appearanceLanguage; - /// Language setting subtitle - /// - /// In en, this message translates to: - /// **'Choose your preferred language'** - String get appearanceLanguageSubtitle; - /// Appearance settings description /// /// In en, this message translates to: @@ -2884,24 +1930,12 @@ abstract class AppLocalizations { /// **'Press back again to exit'** String get pressBackAgainToExit; - /// Section header for track list - /// - /// In en, this message translates to: - /// **'Tracks'** - String get tracksHeader; - /// Download all button with count /// /// In en, this message translates to: /// **'Download All ({count})'** String downloadAllCount(int count); - /// Play all button with count - /// - /// In en, this message translates to: - /// **'Play All ({count})'** - String playAllCount(int count); - /// Track count display /// /// In en, this message translates to: @@ -3100,12 +2134,6 @@ abstract class AppLocalizations { /// **'This will permanently delete the downloaded file and remove it from your history.'** String get trackDeleteConfirmMessage; - /// Error opening file - /// - /// In en, this message translates to: - /// **'Cannot open: {message}'** - String trackCannotOpen(String message); - /// Relative date - today /// /// In en, this message translates to: @@ -3136,30 +2164,6 @@ abstract class AppLocalizations { /// **'{count} months ago'** String dateMonthsAgo(int count); - /// Download mode - one at a time - /// - /// In en, this message translates to: - /// **'Sequential'** - String get concurrentSequential; - - /// Download mode - 2 simultaneous - /// - /// In en, this message translates to: - /// **'2 Parallel'** - String get concurrentParallel2; - - /// Download mode - 3 simultaneous - /// - /// In en, this message translates to: - /// **'3 Parallel'** - String get concurrentParallel3; - - /// Tooltip for failed download - /// - /// In en, this message translates to: - /// **'Tap to see error details'** - String get tapToSeeError; - /// Store filter - all extensions /// /// In en, this message translates to: @@ -3202,24 +2206,6 @@ abstract class AppLocalizations { /// **'Clear filters'** String get storeClearFilters; - /// Empty state when no extensions match filters - /// - /// In en, this message translates to: - /// **'No extensions found'** - String get storeNoResults; - - /// Extension capability - provider priority - /// - /// In en, this message translates to: - /// **'Provider Priority'** - String get extensionProviderPriority; - - /// Button to install extension - /// - /// In en, this message translates to: - /// **'Install Extension'** - String get extensionInstallButton; - /// Default search provider option /// /// In en, this message translates to: @@ -3496,66 +2482,6 @@ abstract class AppLocalizations { /// **'24-bit / up to 192kHz'** String get qualityHiResFlacMaxSubtitle; - /// Quality option - lossy format (MP3/Opus) - /// - /// In en, this message translates to: - /// **'Lossy'** - String get qualityLossy; - - /// Technical spec for lossy MP3 - /// - /// In en, this message translates to: - /// **'MP3 320kbps (converted from FLAC)'** - String get qualityLossyMp3Subtitle; - - /// Technical spec for lossy Opus - /// - /// In en, this message translates to: - /// **'Opus 128kbps (converted from FLAC)'** - String get qualityLossyOpusSubtitle; - - /// Setting - enable lossy quality option - /// - /// In en, this message translates to: - /// **'Enable Lossy Option'** - String get enableLossyOption; - - /// Subtitle when lossy is enabled - /// - /// In en, this message translates to: - /// **'Lossy quality option is available'** - String get enableLossyOptionSubtitleOn; - - /// Subtitle when lossy is disabled - /// - /// In en, this message translates to: - /// **'Downloads FLAC then converts to lossy format'** - String get enableLossyOptionSubtitleOff; - - /// Setting - choose lossy format - /// - /// In en, this message translates to: - /// **'Lossy Format'** - String get lossyFormat; - - /// Description for lossy format picker - /// - /// In en, this message translates to: - /// **'Choose the lossy format for conversion'** - String get lossyFormatDescription; - - /// MP3 format description - /// - /// In en, this message translates to: - /// **'320kbps, best compatibility'** - String get lossyFormatMp3Subtitle; - - /// Opus format description - /// - /// In en, this message translates to: - /// **'128kbps, better quality at smaller size'** - String get lossyFormatOpusSubtitle; - /// Note about quality availability /// /// In en, this message translates to: @@ -3580,30 +2506,6 @@ abstract class AppLocalizations { /// **'YouTube MP3 Bitrate'** String get youtubeMp3BitrateTitle; - /// Subtitle showing current bitrate and valid range - /// - /// In en, this message translates to: - /// **'{bitrate}kbps ({min}-{max})'** - String youtubeBitrateSubtitle(int bitrate, int min, int max); - - /// Helper text for manual YouTube bitrate input - /// - /// In en, this message translates to: - /// **'Enter custom bitrate ({min}-{max} kbps)'** - String youtubeBitrateInputHelp(int min, int max); - - /// Label for YouTube bitrate input field - /// - /// In en, this message translates to: - /// **'Bitrate (kbps)'** - String get youtubeBitrateFieldLabel; - - /// Validation error for invalid YouTube bitrate input - /// - /// In en, this message translates to: - /// **'Bitrate must be between {min} and {max} kbps'** - String youtubeBitrateValidationError(int min, int max); - /// Setting - show quality picker /// /// In en, this message translates to: @@ -3634,18 +2536,6 @@ abstract class AppLocalizations { /// **'Use Album Artist for folders'** String get downloadUseAlbumArtistForFolders; - /// Subtitle when Album Artist is used for folder naming - /// - /// In en, this message translates to: - /// **'Artist folders use Album Artist when available'** - String get downloadUseAlbumArtistForFoldersAlbumSubtitle; - - /// Subtitle when Track Artist is used for folder naming - /// - /// In en, this message translates to: - /// **'Artist folders use Track Artist only'** - String get downloadUseAlbumArtistForFoldersTrackSubtitle; - /// Setting - strip featured artists from folder name /// /// In en, this message translates to: @@ -3664,18 +2554,6 @@ abstract class AppLocalizations { /// **'Full artist string used for folder name'** String get downloadUsePrimaryArtistOnlyDisabled; - /// Setting - output file format - /// - /// In en, this message translates to: - /// **'Save Format'** - String get downloadSaveFormat; - - /// Dialog title - choose download service - /// - /// In en, this message translates to: - /// **'Select Service'** - String get downloadSelectService; - /// Dialog title - choose audio quality /// /// In en, this message translates to: @@ -3688,96 +2566,6 @@ abstract class AppLocalizations { /// **'Download From'** String get downloadFrom; - /// Label - default quality setting - /// - /// In en, this message translates to: - /// **'Default Quality'** - String get downloadDefaultQualityLabel; - - /// Quality option - highest available - /// - /// In en, this message translates to: - /// **'Best available'** - String get downloadBestAvailable; - - /// Folder option - no organization - /// - /// In en, this message translates to: - /// **'None'** - String get folderNone; - - /// Subtitle for no folder organization - /// - /// In en, this message translates to: - /// **'Save all files directly to download folder'** - String get folderNoneSubtitle; - - /// Folder option - by artist - /// - /// In en, this message translates to: - /// **'Artist'** - String get folderArtist; - - /// Folder structure example - /// - /// In en, this message translates to: - /// **'Artist Name/filename'** - String get folderArtistSubtitle; - - /// Folder option - by album - /// - /// In en, this message translates to: - /// **'Album'** - String get folderAlbum; - - /// Folder structure example - /// - /// In en, this message translates to: - /// **'Album Name/filename'** - String get folderAlbumSubtitle; - - /// Folder option - nested - /// - /// In en, this message translates to: - /// **'Artist/Album'** - String get folderArtistAlbum; - - /// Folder structure example - /// - /// In en, this message translates to: - /// **'Artist Name/Album Name/filename'** - String get folderArtistAlbumSubtitle; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Tidal'** - String get serviceTidal; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Qobuz'** - String get serviceQobuz; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Amazon'** - String get serviceAmazon; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Deezer'** - String get serviceDeezer; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Spotify'** - String get serviceSpotify; - /// Theme option - pure black /// /// In en, this message translates to: @@ -3790,24 +2578,6 @@ abstract class AppLocalizations { /// **'Pure black background'** String get appearanceAmoledDarkSubtitle; - /// Color picker dialog title - /// - /// In en, this message translates to: - /// **'Choose Accent Color'** - String get appearanceChooseAccentColor; - - /// Theme picker dialog title - /// - /// In en, this message translates to: - /// **'Theme Mode'** - String get appearanceChooseTheme; - - /// Queue screen title - /// - /// In en, this message translates to: - /// **'Download Queue'** - String get queueTitle; - /// Button - clear all queue items /// /// In en, this message translates to: @@ -3820,30 +2590,6 @@ abstract class AppLocalizations { /// **'Are you sure you want to clear all downloads?'** String get queueClearAllMessage; - /// Button - export failed downloads to TXT - /// - /// In en, this message translates to: - /// **'Export'** - String get queueExportFailed; - - /// Success message after exporting failed downloads - /// - /// In en, this message translates to: - /// **'Failed downloads exported to TXT file'** - String get queueExportFailedSuccess; - - /// Action to clear failed downloads after export - /// - /// In en, this message translates to: - /// **'Clear Failed'** - String get queueExportFailedClear; - - /// Error message when export fails - /// - /// In en, this message translates to: - /// **'Failed to export downloads'** - String get queueExportFailedError; - /// Setting toggle for auto-export /// /// In en, this message translates to: @@ -3880,54 +2626,6 @@ abstract class AppLocalizations { /// **'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'** String get settingsDownloadNetworkSubtitle; - /// Empty queue state title - /// - /// In en, this message translates to: - /// **'No downloads in queue'** - String get queueEmpty; - - /// Empty queue state subtitle - /// - /// In en, this message translates to: - /// **'Add tracks from the home screen'** - String get queueEmptySubtitle; - - /// Button - clear finished downloads - /// - /// In en, this message translates to: - /// **'Clear completed'** - String get queueClearCompleted; - - /// Error dialog title - /// - /// In en, this message translates to: - /// **'Download Failed'** - String get queueDownloadFailed; - - /// Label in error dialog - /// - /// In en, this message translates to: - /// **'Track:'** - String get queueTrackLabel; - - /// Label in error dialog - /// - /// In en, this message translates to: - /// **'Artist:'** - String get queueArtistLabel; - - /// Label in error dialog - /// - /// In en, this message translates to: - /// **'Error:'** - String get queueErrorLabel; - - /// Fallback error message - /// - /// In en, this message translates to: - /// **'Unknown error'** - String get queueUnknownError; - /// Album folder option /// /// In en, this message translates to: @@ -4000,18 +2698,6 @@ abstract class AppLocalizations { /// **'Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.'** String downloadedAlbumDeleteMessage(int count); - /// Section header for tracks - /// - /// In en, this message translates to: - /// **'Tracks'** - String get downloadedAlbumTracksHeader; - - /// Downloaded tracks count badge - /// - /// In en, this message translates to: - /// **'{count} downloaded'** - String downloadedAlbumDownloadedCount(int count); - /// Selection count indicator /// /// In en, this message translates to: @@ -4048,12 +2734,6 @@ abstract class AppLocalizations { /// **'Disc {discNumber}'** String downloadedAlbumDiscHeader(int discNumber); - /// Extension capability - utility functions - /// - /// In en, this message translates to: - /// **'Utility Functions'** - String get utilityFunctions; - /// Recent access item type - artist /// /// In en, this message translates to: @@ -4096,36 +2776,18 @@ abstract class AppLocalizations { /// **'Playlist: {name}'** String recentPlaylistInfo(String name); - /// Generic error message format - /// - /// In en, this message translates to: - /// **'Error: {message}'** - String errorGeneric(String message); - /// Button - download artist discography /// /// In en, this message translates to: /// **'Download Discography'** String get discographyDownload; - /// Button - play artist discography - /// - /// In en, this message translates to: - /// **'Play Discography'** - String get discographyPlay; - /// Option - download entire discography /// /// In en, this message translates to: /// **'Download All'** String get discographyDownloadAll; - /// Option - play entire discography - /// - /// In en, this message translates to: - /// **'Play All'** - String get discographyPlayAll; - /// Subtitle showing total tracks and albums /// /// In en, this message translates to: @@ -4192,12 +2854,6 @@ abstract class AppLocalizations { /// **'Download Selected'** String get discographyDownloadSelected; - /// Button - play selected albums - /// - /// In en, this message translates to: - /// **'Play Selected'** - String get discographyPlaySelected; - /// Snackbar - tracks added from discography /// /// In en, this message translates to: @@ -4294,12 +2950,6 @@ abstract class AppLocalizations { /// **'Local Library'** String get libraryTitle; - /// Section header for library status - /// - /// In en, this message translates to: - /// **'Library Status'** - String get libraryStatus; - /// Section header for scan settings /// /// In en, this message translates to: @@ -4414,12 +3064,6 @@ abstract class AppLocalizations { /// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'** String get libraryAboutDescription; - /// Track count in library - /// - /// In en, this message translates to: - /// **'{count} tracks'** - String libraryTracksCount(int count); - /// Unit label for tracks count (without the number itself) /// /// In en, this message translates to: @@ -4570,36 +3214,6 @@ abstract class AppLocalizations { /// **'Format'** String get libraryFilterFormat; - /// Filter section - date range - /// - /// In en, this message translates to: - /// **'Date Added'** - String get libraryFilterDate; - - /// Filter option - today only - /// - /// In en, this message translates to: - /// **'Today'** - String get libraryFilterDateToday; - - /// Filter option - this week - /// - /// In en, this message translates to: - /// **'This Week'** - String get libraryFilterDateWeek; - - /// Filter option - this month - /// - /// In en, this message translates to: - /// **'This Month'** - String get libraryFilterDateMonth; - - /// Filter option - this year - /// - /// In en, this message translates to: - /// **'This Year'** - String get libraryFilterDateYear; - /// Filter section - sort order /// /// In en, this message translates to: @@ -4618,12 +3232,6 @@ abstract class AppLocalizations { /// **'Oldest'** String get libraryFilterSortOldest; - /// Badge showing number of active filters - /// - /// In en, this message translates to: - /// **'{count} filter(s) active'** - String libraryFilterActive(int count); - /// Relative time - less than a minute ago /// /// In en, this message translates to: @@ -4642,114 +3250,6 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'** String timeHoursAgo(int count); - /// Dialog title when switching storage mode - /// - /// In en, this message translates to: - /// **'Switch Storage Mode'** - String get storageSwitchTitle; - - /// Dialog title when switching to SAF - /// - /// In en, this message translates to: - /// **'Switch to SAF Storage?'** - String get storageSwitchToSafTitle; - - /// Dialog title when switching to app storage - /// - /// In en, this message translates to: - /// **'Switch to App Storage?'** - String get storageSwitchToAppTitle; - - /// Explanation when switching to SAF - /// - /// In en, this message translates to: - /// **'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'** - String get storageSwitchToSafMessage; - - /// Explanation when switching to app storage - /// - /// In en, this message translates to: - /// **'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'** - String get storageSwitchToAppMessage; - - /// Section header for existing downloads info - /// - /// In en, this message translates to: - /// **'Existing Downloads'** - String get storageSwitchExistingDownloads; - - /// Info about existing downloads count - /// - /// In en, this message translates to: - /// **'{count} tracks in {mode} storage'** - String storageSwitchExistingDownloadsInfo(int count, String mode); - - /// Section header for new downloads info - /// - /// In en, this message translates to: - /// **'New Downloads'** - String get storageSwitchNewDownloads; - - /// Shows where new downloads will go - /// - /// In en, this message translates to: - /// **'Will be saved to: {location}'** - String storageSwitchNewDownloadsLocation(String location); - - /// Button to proceed with storage switch - /// - /// In en, this message translates to: - /// **'Continue'** - String get storageSwitchContinue; - - /// Button to select SAF folder - /// - /// In en, this message translates to: - /// **'Select SAF Folder'** - String get storageSwitchSelectFolder; - - /// Label for app storage mode - /// - /// In en, this message translates to: - /// **'App Storage'** - String get storageAppStorage; - - /// Label for SAF storage mode - /// - /// In en, this message translates to: - /// **'SAF Storage'** - String get storageSafStorage; - - /// Badge showing storage mode for a track - /// - /// In en, this message translates to: - /// **'Storage: {mode}'** - String storageModeBadge(String mode); - - /// Section title for storage stats - /// - /// In en, this message translates to: - /// **'Storage Statistics'** - String get storageStatsTitle; - - /// Count of tracks in app storage - /// - /// In en, this message translates to: - /// **'{count} tracks in App Storage'** - String storageStatsAppCount(int count); - - /// Count of tracks in SAF storage - /// - /// In en, this message translates to: - /// **'{count} tracks in SAF Storage'** - String storageStatsSafCount(int count); - - /// Info when user has files in both storage modes - /// - /// In en, this message translates to: - /// **'Your files are stored in multiple locations'** - String get storageModeInfo; - /// Tutorial welcome page title /// /// In en, this message translates to: @@ -4792,24 +3292,6 @@ abstract class AppLocalizations { /// **'There are two easy ways to find music you want to download.'** String get tutorialSearchDesc; - /// Tutorial search tip 1 - /// - /// In en, this message translates to: - /// **'Paste a Spotify or Deezer URL directly in the search box'** - String get tutorialSearchTip1; - - /// Tutorial search tip 2 - /// - /// In en, this message translates to: - /// **'Or type the song name, artist, or album to search'** - String get tutorialSearchTip2; - - /// Tutorial search tip 3 - /// - /// In en, this message translates to: - /// **'Supports tracks, albums, playlists, and artist pages'** - String get tutorialSearchTip3; - /// Tutorial download page title /// /// In en, this message translates to: @@ -4822,24 +3304,6 @@ abstract class AppLocalizations { /// **'Downloading music is simple and fast. Here\'s how it works.'** String get tutorialDownloadDesc; - /// Tutorial download tip 1 - /// - /// In en, this message translates to: - /// **'Tap the download button next to any track to start downloading'** - String get tutorialDownloadTip1; - - /// Tutorial download tip 2 - /// - /// In en, this message translates to: - /// **'Choose your preferred quality (FLAC, Hi-Res, or MP3)'** - String get tutorialDownloadTip2; - - /// Tutorial download tip 3 - /// - /// In en, this message translates to: - /// **'Download entire albums or playlists with one tap'** - String get tutorialDownloadTip3; - /// Tutorial library page title /// /// In en, this message translates to: @@ -4936,12 +3400,6 @@ abstract class AppLocalizations { /// **'You\'re all set! Start downloading your favorite music now.'** String get tutorialReadyMessage; - /// Example label in tutorial - /// - /// In en, this message translates to: - /// **'EXAMPLE'** - String get tutorialExample; - /// Button to force a complete rescan of library /// /// In en, this message translates to: @@ -5212,12 +3670,6 @@ abstract class AppLocalizations { /// **'Re-enrich'** String get trackReEnrich; - /// Subtitle for re-enrich metadata action - /// - /// In en, this message translates to: - /// **'Re-embed metadata without re-downloading'** - String get trackReEnrichSubtitle; - /// Subtitle for re-enrich metadata action for local items /// /// In en, this message translates to: @@ -5634,71 +4086,23 @@ abstract class AppLocalizations { /// **'Converted {success} of {total} tracks to {format}'** String selectionBatchConvertSuccess(int success, int total, String format); - /// Title for mode selection step in setup wizard + /// Downloaded tracks count badge /// /// In en, this message translates to: - /// **'Choose Your Mode'** - String get setupModeSelectionTitle; + /// **'{count} downloaded'** + String downloadedAlbumDownloadedCount(int count); - /// Description for mode selection step + /// Subtitle when Album Artist is used for folder naming /// /// In en, this message translates to: - /// **'How would you like to use SpotiFLAC? You can always change this later in Settings.'** - String get setupModeSelectionDescription; + /// **'Artist folders use Album Artist when available'** + String get downloadUseAlbumArtistForFoldersAlbumSubtitle; - /// Title for downloader mode option + /// Subtitle when Track Artist is used for folder naming /// /// In en, this message translates to: - /// **'Downloader'** - String get setupModeDownloaderTitle; - - /// Downloader mode feature 1 - /// - /// In en, this message translates to: - /// **'Download tracks in lossless FLAC quality'** - String get setupModeDownloaderFeature1; - - /// Downloader mode feature 2 - /// - /// In en, this message translates to: - /// **'Save music to your device for offline listening'** - String get setupModeDownloaderFeature2; - - /// Downloader mode feature 3 - /// - /// In en, this message translates to: - /// **'Manage your local music library'** - String get setupModeDownloaderFeature3; - - /// Title for streaming mode option - /// - /// In en, this message translates to: - /// **'Streaming'** - String get setupModeStreamingTitle; - - /// Streaming mode feature 1 - /// - /// In en, this message translates to: - /// **'Stream tracks instantly without downloading'** - String get setupModeStreamingFeature1; - - /// Streaming mode feature 2 - /// - /// In en, this message translates to: - /// **'Smart Queue auto-discovers new music for you'** - String get setupModeStreamingFeature2; - - /// Streaming mode feature 3 - /// - /// In en, this message translates to: - /// **'Play any track on demand with playback controls'** - String get setupModeStreamingFeature3; - - /// Hint that mode can be changed later - /// - /// In en, this message translates to: - /// **'You can switch between modes anytime in Settings.'** - String get setupModeChangeableLater; + /// **'Artist folders use Track Artist only'** + String get downloadUseAlbumArtistForFoldersTrackSubtitle; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f5287500..2b846be4 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -11,19 +11,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; - @override String get navHome => 'Startseite'; @override String get navLibrary => 'Archiv'; - @override - String get navHistory => 'Verlauf'; - @override String get navSettings => 'Einstellungen'; @@ -33,14 +26,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get homeTitle => 'Startseite'; - @override - String get homeSearchHint => 'Spotify-URL einfügen oder suchen...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Mit $extensionName suchen...'; - } - @override String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen'; @@ -51,17 +36,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get homeRecent => 'Zuletzt'; - @override - String get historyTitle => 'Verlauf'; - - @override - String historyDownloading(int count) { - return 'Wird heruntergeladen ($count)'; - } - - @override - String get historyDownloaded => 'Heruntergeladen'; - @override String get historyFilterAll => 'Alle'; @@ -71,49 +45,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Titel', - one: '1 Titel', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Alben', - one: '1 Album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Kein Download-Verlauf'; - - @override - String get historyNoDownloadsSubtitle => - 'Heruntergeladene Titel werden hier angezeigt'; - - @override - String get historyNoAlbums => 'Keine Album-Downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Lade mehrere Titel eines Albums herunter, um sie hier zu sehen'; - - @override - String get historyNoSingles => 'Keine Einzel-Downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Einzelne Titel-Downloads werden hier angezeigt'; - @override String get historySearchHint => 'Suchverlauf...'; @@ -138,27 +69,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadTitle => 'Herunterladen'; - @override - String get downloadLocation => 'Download-Speicherort'; - - @override - String get downloadLocationSubtitle => 'Wähle den Speicherort der Dateien'; - - @override - String get downloadLocationDefault => 'Standard-Speicherort'; - - @override - String get downloadDefaultService => 'Standard-Dienst'; - - @override - String get downloadDefaultServiceSubtitle => 'Dienst für Downloads'; - - @override - String get downloadDefaultQuality => 'Standard-Qualität'; - - @override - String get downloadAskQuality => 'Qualität vor Download abfragen'; - @override String get downloadAskQualitySubtitle => 'Qualitätsauswahl für jeden Download anzeigen'; @@ -169,31 +79,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadFolderOrganization => 'Ordnerstruktur'; - @override - String get downloadSeparateSingles => 'Singles trennen'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Einzelne Titel in separatem Ordner speichern'; - - @override - String get qualityBest => 'Beste Qualität'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Erscheinungsbild'; - @override - String get appearanceTheme => 'Design'; - @override String get appearanceThemeSystem => 'System'; @@ -210,9 +98,6 @@ class AppLocalizationsDe extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Farben von Ihrem Hintergrundbild verwenden'; - @override - String get appearanceAccentColor => 'Akzentfarbe'; - @override String get appearanceHistoryView => 'Verlaufsansicht'; @@ -225,9 +110,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get optionsTitle => 'Optionen'; - @override - String get optionsSearchSource => 'Suchquelle'; - @override String get optionsPrimaryProvider => 'Primärer Anbieter'; @@ -251,33 +133,6 @@ class AppLocalizationsDe extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Andere Dienste versuchen, wenn Download fehlschlägt'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden'; @@ -385,19 +240,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get extensionsTitle => 'Erweiterungen'; - @override - String get extensionsInstalled => 'Installierte Erweiterungen'; - - @override - String get extensionsNone => 'Keine Erweiterungen installiert'; - - @override - String get extensionsNoneSubtitle => - 'Erweiterungen aus dem Store-Tab installieren'; - - @override - String get extensionsEnabled => 'Aktiviert'; - @override String get extensionsDisabled => 'Deaktiviert'; @@ -414,9 +256,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get extensionsUninstall => 'Deinstallieren'; - @override - String get extensionsSetAsSearch => 'Als Suchanbieter festlegen'; - @override String get storeTitle => 'Erweiterungs-Store'; @@ -492,9 +331,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutSocial => 'Sozial'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -513,13 +349,6 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutSjdonadoDesc => 'Ersteller von I Don\'t Have Spotify (IDHS). Der Fallback-Link-Resolver, der den Tag rettete!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Wundervolle API für Amazon Musik-Downloads.'; - @override String get aboutDabMusic => 'DAB Music'; @@ -538,32 +367,6 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutAppDescription => 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Songs', - one: '1 Song', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Alle Herunterladen'; - - @override - String get albumDownloadRemaining => 'Downloads verbleibend'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Künstler'; - @override String get artistAlbums => 'Alben'; @@ -573,17 +376,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get artistCompilations => 'Zusammenstellungen'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Veröffentlichungen', - one: '1 Veröffentlichung', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Beliebt'; @@ -592,27 +384,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$count monatliche Hörer'; } - @override - String get trackMetadataTitle => 'Titel Info'; - - @override - String get trackMetadataArtist => 'Künstler'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Länge'; - - @override - String get trackMetadataQuality => 'Qualität'; - - @override - String get trackMetadataPath => 'Dateipfad'; - - @override - String get trackMetadataDownloadedAt => 'Heruntergeladen'; - @override String get trackMetadataService => 'Anbieter'; @@ -625,53 +396,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackMetadataDelete => 'Löschen'; - @override - String get trackMetadataRedownload => 'Erneut herunterladen'; - - @override - String get trackMetadataOpenFolder => 'Ordner öffnen'; - - @override - String get setupTitle => 'Willkommen bei SpotiFLAC'; - - @override - String get setupSubtitle => 'Los geht\'s'; - - @override - String get setupStoragePermission => 'Speicherberechtigung'; - - @override - String get setupStoragePermissionSubtitle => - 'Benötigt um heruntergeladene Dateien zu Speichern'; - - @override - String get setupStoragePermissionGranted => 'Berechtigung erteilt'; - - @override - String get setupStoragePermissionDenied => 'Berechtigung verweigert'; - @override String get setupGrantPermission => 'Berechtigung erlauben'; - @override - String get setupDownloadLocation => 'Speicherort'; - - @override - String get setupChooseFolder => 'Ordner wählen'; - - @override - String get setupContinue => 'Fortfahren'; - @override String get setupSkip => 'Vorerst überspringen'; @override String get setupStorageAccessRequired => 'Speicherzugriff erforderlich'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.'; @@ -693,9 +426,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$permissionType Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.'; } - @override - String get setupSelectDownloadFolder => 'Wähle Download-Ordner aus'; - @override String get setupUseDefaultFolder => 'Als Standardordner verwenden?'; @@ -738,21 +468,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen'; - @override - String get setupStepStorage => 'Speicherort'; - - @override - String get setupStepNotification => 'Benachrichtigung'; - - @override - String get setupStepFolder => 'Ordner'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Berechtigung'; - @override String get setupStorageGranted => 'Speicherberechtigung erlaubt!'; @@ -770,13 +485,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupNotificationEnable => 'Benachrichtigungen aktivieren'; - @override - String get setupNotificationDescription => - 'Benachrichtigt werden, wenn Downloads abgeschlossen sind.'; - - @override - String get setupFolderSelected => 'Download Ordner ausgewählt!'; - @override String get setupFolderChoose => 'Speicherort auwählen'; @@ -784,49 +492,12 @@ class AppLocalizationsDe extends AppLocalizations { String get setupFolderDescription => 'Wähle einen Ordner, in dem die heruntergeladene Musik gespeichert wird.'; - @override - String get setupChangeFolder => 'Ordner ändern'; - @override String get setupSelectFolder => 'Ordner wählen'; - @override - String get setupSpotifyApiOptional => 'Spotify-API (optional)'; - - @override - String get setupSpotifyApiDescription => - 'Füge deine Spotify-API-Zugangsdaten für bessere Suchergebnisse und den Zugriff auf Spotify-exklusive Inhalte hinzu.'; - - @override - String get setupUseSpotifyApi => 'Spotify-API verwenden'; - - @override - String get setupEnterCredentialsBelow => 'Gib deine Anmeldedaten unten ein'; - - @override - String get setupUsingDeezer => 'Deezer verwenden (kein Konto erforderlich)'; - - @override - String get setupEnterClientId => 'Spotify-Client-ID eingeben'; - - @override - String get setupEnterClientSecret => 'Spotify Client-Secret eingeben'; - - @override - String get setupGetFreeCredentials => - 'Hole dir kostenlose API-Anmeldeinformationen aus dem Spotify-Entwickler-Dashboard.'; - @override String get setupEnableNotifications => 'Benachrichtigungen aktivieren'; - @override - String get setupProceedToNextStep => - 'Du kannst mit dem nächsten Schritt fortfahren.'; - - @override - String get setupNotificationProgressDescription => - 'Du erhältst Benachrichtigungen über den Download-Fortschritt.'; - @override String get setupNotificationBackgroundDescription => 'Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.'; @@ -834,32 +505,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupSkipForNow => 'Vorerst überspringen'; - @override - String get setupBack => 'Zurück'; - @override String get setupNext => 'Weiter'; @override String get setupGetStarted => 'Los geht‘s'; - @override - String get setupSkipAndStart => 'Überspringen & Starten'; - @override String get setupAllowAccessToManageFiles => 'Bitte aktiviere \"Zugriff auf alle Dateien erlauben\" auf dem nächsten Bildschirm.'; - @override - String get setupGetCredentialsFromSpotify => - 'Zugangsdaten von developer.spotify.com erhalten'; - @override String get dialogCancel => 'Abbrechen'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Speichern'; @@ -869,21 +527,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dialogRetry => 'Wiederholen'; - @override - String get dialogClose => 'Schließen'; - - @override - String get dialogYes => 'Ja'; - - @override - String get dialogNo => 'Nein'; - @override String get dialogClear => 'Leeren'; - @override - String get dialogConfirm => 'Bestätigen'; - @override String get dialogDone => 'Fertig'; @@ -906,28 +552,9 @@ class AppLocalizationsDe extends AppLocalizations { String get dialogUnsavedChanges => 'Hast du noch nicht alle Änderungen gespeichert. Möchtest du die Änderungen verwerfen?'; - @override - String get dialogDownloadFailed => 'Download fehlgeschlagen'; - - @override - String get dialogTrackLabel => 'Titel:'; - - @override - String get dialogArtistLabel => 'Künstler:'; - - @override - String get dialogErrorLabel => 'Fehler:'; - @override String get dialogClearAll => 'Alles löschen'; - @override - String get dialogClearAllDownloads => - 'Bist du dir sicher, dass du alle Downloads löschen möchten?'; - - @override - String get dialogRemoveFromDevice => 'Vom Gerät entfernen?'; - @override String get dialogRemoveExtension => 'Erweiterung entfernen'; @@ -1028,11 +655,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get snackbarViewQueue => 'Warteschlange anzeigen'; - @override - String snackbarFailedToLoad(String error) { - return 'Fehler beim Laden: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL in die Zwischenablage kopiert'; @@ -1076,44 +698,14 @@ class AppLocalizationsDe extends AppLocalizations { String get errorRateLimitedMessage => 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut suchst.'; - @override - String errorFailedToLoad(String item) { - return 'Fehler beim Laden von: $item'; - } - @override String get errorNoTracksFound => 'Keine Titel gefunden'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle'; } - @override - String get statusQueued => 'In der Warteschlange'; - - @override - String get statusDownloading => 'Wird heruntergeladen'; - - @override - String get statusFinalizing => 'Wird fertiggestellt'; - - @override - String get statusCompleted => 'Beendet'; - - @override - String get statusFailed => 'Fehlgeschlagen'; - - @override - String get statusSkipped => 'Übersprungen'; - - @override - String get statusPaused => 'Pausiert'; - @override String get actionPause => 'Pause'; @@ -1123,24 +715,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get actionCancel => 'Abbrechen'; - @override - String get actionStop => 'Beenden'; - - @override - String get actionSelect => 'Wähle'; - @override String get actionSelectAll => 'Alles Auswählen'; @override String get actionDeselect => 'Alle abwählen'; - @override - String get actionPaste => 'Einfügen'; - - @override - String get actionImportCsv => 'CSV-Datei importieren'; - @override String get actionRemoveCredentials => 'Anmeldedaten entfernen'; @@ -1155,20 +735,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectionAllSelected => 'Alle Titel sind ausgewählt'; - @override - String get selectionTapToSelect => 'Tippe auf Titel zum Auswählen'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Titel', - one: 'Titel', - ); - return 'Lösche $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Titel zum Löschen auswählen'; @@ -1195,40 +761,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tooltipPlay => 'Abspielen'; - @override - String get tooltipCancel => 'Abbrechen'; - - @override - String get tooltipStop => 'Beenden'; - - @override - String get tooltipRetry => 'Wiederholen'; - - @override - String get tooltipRemove => 'Entfernen'; - - @override - String get tooltipClear => 'Leeren'; - - @override - String get tooltipPaste => 'Einfügen'; - @override String get filenameFormat => 'Dateinamenformat'; - @override - String filenameFormatPreview(String preview) { - return 'Vorschau: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Verfügbare Platzhalter:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1236,9 +771,6 @@ class AppLocalizationsDe extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Ordnerstruktur'; - @override String get folderOrganizationNone => 'Keine Organisation'; @@ -1273,20 +805,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get updateAvailable => 'Update verfügbar'; - @override - String updateNewVersion(String version) { - return 'Version $version ist verfügbar'; - } - - @override - String get updateDownload => 'Herunterladen'; - @override String get updateLater => 'Später'; - @override - String get updateChangelog => 'Änderungsverlauf'; - @override String get updateStartingDownload => 'Download wird gestartet...'; @@ -1318,13 +839,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get updateDontRemind => 'Nicht erinnern'; - @override - String get providerPriority => 'Anbieterpriorität'; - - @override - String get providerPrioritySubtitle => - 'Ziehen, um Download-Anbieter neu anzuordnen'; - @override String get providerPriorityTitle => 'Anbieterpriorität'; @@ -1342,13 +856,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get providerExtension => 'Erweiterung'; - @override - String get metadataProviderPriority => 'Priorität des Metadaten-Anbieters'; - - @override - String get metadataProviderPrioritySubtitle => - 'Reihenfolge beim Abrufen von Titelmetadaten'; - @override String get metadataProviderPriorityTitle => 'Metadaten Priorität'; @@ -1369,18 +876,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get logTitle => 'Protokolle'; - @override - String get logCopy => 'Protokolle kopieren'; - - @override - String get logClear => 'Protokolle löschen'; - - @override - String get logShare => 'Protokolle teilen'; - - @override - String get logEmpty => 'Keine Protokolle bisher'; - @override String get logCopied => 'Protokolle in Zwischenablage kopiert'; @@ -1406,18 +901,6 @@ class AppLocalizationsDe extends AppLocalizations { String get logClearLogsMessage => 'Bist du dir sicher, dass Sie alle Protokolle löschen möchtest?'; - @override - String get logIspBlocking => 'ISP BLOCKIERUNG ERKANNT'; - - @override - String get logRateLimited => 'LIMIT ERKANNT'; - - @override - String get logNetworkError => 'NETZWERKFEHLER'; - - @override - String get logTrackNotFound => 'TITEL NICHT GEFUNDEN'; - @override String get logFilterBySeverity => 'Protokolle nach Schweregrad filtern'; @@ -1428,48 +911,6 @@ class AppLocalizationsDe extends AppLocalizations { String get logNoLogsYetSubtitle => 'Protokolle werden hier angezeigt, während du die App benutzt'; - @override - String get logIssueSummary => 'Problemübersicht'; - - @override - String get logIspBlockingDescription => - 'Ihr ISP blockiert möglicherweise den Zugriff auf den Download Dienst'; - - @override - String get logIspBlockingSuggestion => - 'Versuche es einem VPN oder ändere DNS auf 1.1.1.1 oder 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Zu viele Anfragen an den Dienst'; - - @override - String get logRateLimitedSuggestion => - 'Warte ein paar Minuten, bevor du es erneut versuchst'; - - @override - String get logNetworkErrorDescription => 'Verbindungsprobleme erkannt'; - - @override - String get logNetworkErrorSuggestion => 'Überprüfe deine Internetverbindung'; - - @override - String get logTrackNotFoundDescription => - 'Einige Titel konnten auf Download-Diensten nicht gefunden werden'; - - @override - String get logTrackNotFoundSuggestion => - 'Der Titel ist möglicherweise nicht in verlustfreier Qualität verfügbar'; - - @override - String logTotalErrors(int count) { - return 'Gesamte Fehler: $count'; - } - - @override - String logAffected(String domains) { - return 'Betroffen: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Einträge ($count gefiltert)'; @@ -1577,9 +1018,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appearanceLanguage => 'App Sprache'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1601,19 +1039,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1722,11 +1152,6 @@ class AppLocalizationsDe extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1748,18 +1173,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1781,15 +1194,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1940,38 +1344,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1986,24 +1358,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -2019,14 +1373,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2038,78 +1384,18 @@ class AppLocalizationsDe extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2117,19 +1403,6 @@ class AppLocalizationsDe extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2150,30 +1423,6 @@ class AppLocalizationsDe extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2220,14 +1469,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2258,9 +1499,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2284,23 +1522,12 @@ class AppLocalizationsDe extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2345,9 +1572,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2403,9 +1627,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2468,11 +1689,6 @@ class AppLocalizationsDe extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2563,21 +1779,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2587,11 +1788,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2617,72 +1813,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2709,18 +1839,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2728,18 +1846,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2800,9 +1906,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2965,10 +2068,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3249,43 +2348,15 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Wähle deinen Modus'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Downloader'; - - @override - String get setupModeDownloaderFeature1 => - 'Lade Titel in verlustfreier FLAC-Qualität herunter'; - - @override - String get setupModeDownloaderFeature2 => - 'Speichere Musik auf deinem Gerät zum Offline-Hören'; - - @override - String get setupModeDownloaderFeature3 => - 'Verwalte deine lokale Musikbibliothek'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Streame Titel sofort ohne Herunterladen'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue entdeckt automatisch neue Musik für dich'; - - @override - String get setupModeStreamingFeature3 => - 'Spiele jeden Titel auf Abruf mit Wiedergabesteuerung'; - - @override - String get setupModeChangeableLater => - 'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e5da5324..8f244a7f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -11,19 +11,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -248,33 +131,6 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -377,18 +233,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -405,9 +249,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -481,9 +322,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -502,13 +340,6 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -527,32 +358,6 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -562,17 +367,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -581,27 +375,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -614,53 +387,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -682,9 +417,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -726,21 +458,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -757,13 +474,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -771,48 +481,12 @@ class AppLocalizationsEn extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -820,32 +494,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -855,21 +516,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -892,28 +541,9 @@ class AppLocalizationsEn extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1014,11 +644,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1060,44 +685,14 @@ class AppLocalizationsEn extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1107,24 +702,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1139,20 +722,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1179,40 +748,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1220,9 +758,6 @@ class AppLocalizationsEn extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1257,20 +792,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1301,12 +825,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1324,13 +842,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1351,18 +862,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1387,18 +886,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1408,48 +895,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1556,9 +1001,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1580,19 +1022,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1701,11 +1135,6 @@ class AppLocalizationsEn extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1727,18 +1156,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1760,15 +1177,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1919,38 +1327,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1965,24 +1341,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1998,14 +1356,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2017,78 +1367,18 @@ class AppLocalizationsEn extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2096,19 +1386,6 @@ class AppLocalizationsEn extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2129,30 +1406,6 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2199,14 +1452,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2237,9 +1482,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2263,23 +1505,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2324,9 +1555,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2382,9 +1610,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2447,11 +1672,6 @@ class AppLocalizationsEn extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2542,21 +1762,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2566,11 +1771,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2596,72 +1796,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2688,18 +1822,6 @@ class AppLocalizationsEn extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2707,18 +1829,6 @@ class AppLocalizationsEn extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2779,9 +1889,6 @@ class AppLocalizationsEn extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2944,10 +2051,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3228,42 +2331,15 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Choose Your Mode'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'How would you like to use SpotiFLAC? You can always change this later in Settings.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Downloader'; - - @override - String get setupModeDownloaderFeature1 => - 'Download tracks in lossless FLAC quality'; - - @override - String get setupModeDownloaderFeature2 => - 'Save music to your device for offline listening'; - - @override - String get setupModeDownloaderFeature3 => 'Manage your local music library'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Stream tracks instantly without downloading'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue auto-discovers new music for you'; - - @override - String get setupModeStreamingFeature3 => - 'Play any track on demand with playback controls'; - - @override - String get setupModeChangeableLater => - 'You can switch between modes anytime in Settings.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 303eb62b..58ae25af 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -11,19 +11,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -248,33 +131,6 @@ class AppLocalizationsEs extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -377,18 +233,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -405,9 +249,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -481,9 +322,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -502,13 +340,6 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -527,32 +358,6 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -562,17 +367,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -581,27 +375,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -614,53 +387,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -682,9 +417,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -726,21 +458,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -757,13 +474,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -771,48 +481,12 @@ class AppLocalizationsEs extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -820,32 +494,19 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -855,21 +516,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -892,28 +541,9 @@ class AppLocalizationsEs extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1014,11 +644,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1060,44 +685,14 @@ class AppLocalizationsEs extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1107,24 +702,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1139,20 +722,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1179,40 +748,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1220,9 +758,6 @@ class AppLocalizationsEs extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1257,20 +792,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1301,12 +825,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1324,13 +842,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1351,18 +862,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1387,18 +886,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1408,48 +895,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1556,9 +1001,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1580,19 +1022,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1701,11 +1135,6 @@ class AppLocalizationsEs extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1727,18 +1156,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1760,15 +1177,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1919,38 +1327,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1965,24 +1341,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1998,14 +1356,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2017,78 +1367,18 @@ class AppLocalizationsEs extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2096,19 +1386,6 @@ class AppLocalizationsEs extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2129,30 +1406,6 @@ class AppLocalizationsEs extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2199,14 +1452,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2237,9 +1482,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2263,23 +1505,12 @@ class AppLocalizationsEs extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2324,9 +1555,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2382,9 +1610,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2447,11 +1672,6 @@ class AppLocalizationsEs extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2542,21 +1762,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2566,11 +1771,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2596,72 +1796,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2688,18 +1822,6 @@ class AppLocalizationsEs extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2707,18 +1829,6 @@ class AppLocalizationsEs extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2779,9 +1889,6 @@ class AppLocalizationsEs extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2944,10 +2051,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3228,45 +2331,17 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Elige tu modo'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - '¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Descargador'; - - @override - String get setupModeDownloaderFeature1 => - 'Descarga pistas en calidad FLAC sin pérdida'; - - @override - String get setupModeDownloaderFeature2 => - 'Guarda música en tu dispositivo para escuchar sin conexión'; - - @override - String get setupModeDownloaderFeature3 => - 'Gestiona tu biblioteca de música local'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Transmite pistas al instante sin descargar'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue descubre automáticamente nueva música para ti'; - - @override - String get setupModeStreamingFeature3 => - 'Reproduce cualquier pista bajo demanda con controles de reproducción'; - - @override - String get setupModeChangeableLater => - 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). @@ -3276,19 +2351,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; - @override String get navHome => 'Inicio'; @override String get navLibrary => 'Biblioteca'; - @override - String get navHistory => 'Historial'; - @override String get navSettings => 'Ajustes'; @@ -3298,14 +2366,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get homeTitle => 'Inicio'; - @override - String get homeSearchHint => 'Pegar URL Spotify o buscar...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Buscar con $extensionName...'; - } - @override String get homeSubtitle => 'Pegar enlace de Spotify o buscar por nombre'; @@ -3316,17 +2376,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get homeRecent => 'Recientes'; - @override - String get historyTitle => 'Historial'; - - @override - String historyDownloading(int count) { - return 'Descargando ($count)'; - } - - @override - String get historyDownloaded => 'Descargado'; - @override String get historyFilterAll => 'Todo'; @@ -3336,49 +2385,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get historyFilterSingles => 'Pistas'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count pistas', - one: '1 pista', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count álbumes', - one: '1 álbum', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No hay historial de descargas'; - - @override - String get historyNoDownloadsSubtitle => - 'Las pistas descargadas aparecerán aquí'; - - @override - String get historyNoAlbums => 'No hay descargas de álbum'; - - @override - String get historyNoAlbumsSubtitle => - 'Descargar múltiples pistas de un álbum para verlas aquí'; - - @override - String get historyNoSingles => 'No hay descargas'; - - @override - String get historyNoSinglesSubtitle => - 'Las descargas de una sola pista aparecerán aquí'; - @override String get historySearchHint => 'Buscar en historial...'; @@ -3403,27 +2409,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadTitle => 'Descargar'; - @override - String get downloadLocation => 'Ubicación de descarga'; - - @override - String get downloadLocationSubtitle => 'Elija dónde guardar los archivos'; - - @override - String get downloadLocationDefault => 'Ubicación predeterminada'; - - @override - String get downloadDefaultService => 'Servicio por defecto'; - - @override - String get downloadDefaultServiceSubtitle => 'Servicio usado para descargas'; - - @override - String get downloadDefaultQuality => 'Calidad por defecto'; - - @override - String get downloadAskQuality => 'Preguntar calidad antes de descargar'; - @override String get downloadAskQualitySubtitle => 'Mostrar selector de calidad para cada descarga'; @@ -3434,31 +2419,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadFolderOrganization => 'Organización de carpetas'; - @override - String get downloadSeparateSingles => 'Separar Pistas'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Colocar pistas individuales en una carpeta separada'; - - @override - String get qualityBest => 'Mejor disponible'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Apariencia'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistema'; @@ -3475,9 +2438,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get appearanceDynamicColorSubtitle => 'Usar colores de tu fondo de pantalla'; - @override - String get appearanceAccentColor => 'Color Secundario'; - @override String get appearanceHistoryView => 'Vista de Historial'; @@ -3490,9 +2450,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get optionsTitle => 'Opciones'; - @override - String get optionsSearchSource => 'Buscar Fuente'; - @override String get optionsPrimaryProvider => 'Proveedor Principal'; @@ -3623,19 +2580,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get extensionsTitle => 'Extensiones'; - @override - String get extensionsInstalled => 'Extensiones instaladas'; - - @override - String get extensionsNone => 'No hay extensiones instaladas'; - - @override - String get extensionsNoneSubtitle => - 'Instalar extensiones desde la pestaña Tienda'; - - @override - String get extensionsEnabled => 'Habilitado'; - @override String get extensionsDisabled => 'Deshabilitado'; @@ -3652,9 +2596,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get extensionsUninstall => 'Desinstalar'; - @override - String get extensionsSetAsSearch => 'Establecer como proveedor de búsqueda'; - @override String get storeTitle => 'Tienda de extensiones'; @@ -3730,9 +2671,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get aboutSocial => 'Redes sociales'; - @override - String get aboutSupport => 'Soporte'; - @override String get aboutApp => 'Aplicación'; @@ -3751,13 +2689,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get aboutSjdonadoDesc => 'Creador de I No tengo Spotify (IDHS). ¡La solución de enlace de reserva que salva el día!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!'; - @override String get aboutDabMusic => 'Música DAB'; @@ -3776,32 +2707,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get aboutAppDescription => 'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; - @override - String get albumTitle => 'Álbum'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count pistas', - one: '1 pista', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Descargar Todo'; - - @override - String get albumDownloadRemaining => 'Descargas Restantes'; - - @override - String get playlistTitle => 'Lista de reproducción'; - - @override - String get artistTitle => 'Artista'; - @override String get artistAlbums => 'Álbumes'; @@ -3811,17 +2716,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get artistCompilations => 'Compilaciones'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lanzamientos', - one: '1 lanzamiento', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Populares'; @@ -3830,27 +2724,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '$count oyentes mensuales'; } - @override - String get trackMetadataTitle => 'Información de pista'; - - @override - String get trackMetadataArtist => 'Artista'; - - @override - String get trackMetadataAlbum => 'Álbum'; - - @override - String get trackMetadataDuration => 'Duración'; - - @override - String get trackMetadataQuality => 'Calidad'; - - @override - String get trackMetadataPath => 'Ruta del archivo'; - - @override - String get trackMetadataDownloadedAt => 'Descargado'; - @override String get trackMetadataService => 'Servicio'; @@ -3863,53 +2736,15 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get trackMetadataDelete => 'Eliminar'; - @override - String get trackMetadataRedownload => 'Volver a descargar'; - - @override - String get trackMetadataOpenFolder => 'Abrir carpeta'; - - @override - String get setupTitle => 'Bienvenido a SpotiFLAC'; - - @override - String get setupSubtitle => 'Comencemos'; - - @override - String get setupStoragePermission => 'Permiso de almacenamiento'; - - @override - String get setupStoragePermissionSubtitle => - 'Necesario para guardar los archivos descargados'; - - @override - String get setupStoragePermissionGranted => 'Permiso aprobado'; - - @override - String get setupStoragePermissionDenied => 'Permiso denegado'; - @override String get setupGrantPermission => 'Conceder permiso'; - @override - String get setupDownloadLocation => 'Ubicación de descarga'; - - @override - String get setupChooseFolder => 'Seleccionar Carpeta'; - - @override - String get setupContinue => 'Continuar'; - @override String get setupSkip => 'Omitir por ahora'; @override String get setupStorageAccessRequired => 'Acceso al almacenamiento requerido'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.'; @@ -3931,9 +2766,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'Se requiere un permiso $permissionType para la mejor experiencia. Puedes cambiar esto más tarde en ajustes.'; } - @override - String get setupSelectDownloadFolder => 'Seleccionar carpeta de descarga'; - @override String get setupUseDefaultFolder => '¿Usar carpeta por defecto?'; @@ -3976,21 +2808,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupDownloadInFlac => 'Descargar pistas de Spotify en FLAC'; - @override - String get setupStepStorage => 'Almacenamiento'; - - @override - String get setupStepNotification => 'Notificación'; - - @override - String get setupStepFolder => 'Carpeta'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permiso'; - @override String get setupStorageGranted => '¡Permiso de almacenamiento concedido!'; @@ -4008,13 +2825,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupNotificationEnable => 'Activar notificaciones'; - @override - String get setupNotificationDescription => - 'Recibe notificaciones cuando las descargas completen o requieran atención.'; - - @override - String get setupFolderSelected => '¡Carpeta de descarga seleccionada!'; - @override String get setupFolderChoose => 'Cambiar carpeta de descargas'; @@ -4022,50 +2832,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get setupFolderDescription => 'Seleccione una carpeta donde se guardará la música descargada.'; - @override - String get setupChangeFolder => 'Cambiar carpeta'; - @override String get setupSelectFolder => 'Seleccionar Carpeta'; - @override - String get setupSpotifyApiOptional => 'API de Spotify (opcional)'; - - @override - String get setupSpotifyApiDescription => - 'Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.'; - - @override - String get setupUseSpotifyApi => 'Usar API de Spotify'; - - @override - String get setupEnterCredentialsBelow => - 'Ingresa tus credenciales a continuación'; - - @override - String get setupUsingDeezer => 'Usando Deezer (no se necesita cuenta)'; - - @override - String get setupEnterClientId => 'Introduzca el ID de cliente de Spotify'; - - @override - String get setupEnterClientSecret => 'Ingresa el Client Secret de Spotify'; - - @override - String get setupGetFreeCredentials => - 'Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Activar notificaciones'; - @override - String get setupProceedToNextStep => - 'Ahora puedes continuar con el siguiente paso.'; - - @override - String get setupNotificationProgressDescription => - 'Recibirás notificaciones de progreso de descargas.'; - @override String get setupNotificationBackgroundDescription => 'Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.'; @@ -4073,32 +2845,19 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupSkipForNow => 'Omitir por ahora'; - @override - String get setupBack => 'Atrás'; - @override String get setupNext => 'Siguiente'; @override String get setupGetStarted => 'Empezar'; - @override - String get setupSkipAndStart => 'Saltar y empezar'; - @override String get setupAllowAccessToManageFiles => 'Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.'; - @override - String get setupGetCredentialsFromSpotify => - 'Obtener credenciales de developer.spotify.com'; - @override String get dialogCancel => 'Cancelar'; - @override - String get dialogOk => 'Aceptar'; - @override String get dialogSave => 'Guardar'; @@ -4108,21 +2867,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get dialogRetry => 'Volver a intentar'; - @override - String get dialogClose => 'Cerrar'; - - @override - String get dialogYes => 'Sí'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Borrar'; - @override - String get dialogConfirm => 'Confirmar'; - @override String get dialogDone => 'Hecho'; @@ -4145,28 +2892,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get dialogUnsavedChanges => 'Tienes cambios sin guardar. ¿Quieres descartarlos?'; - @override - String get dialogDownloadFailed => 'Descarga fallida'; - - @override - String get dialogTrackLabel => 'Pista:'; - - @override - String get dialogArtistLabel => 'Artista:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Eliminar todo'; - @override - String get dialogClearAllDownloads => - '¿Estás seguro de que quieres borrar todas las descargas?'; - - @override - String get dialogRemoveFromDevice => '¿Eliminar del dispositivo?'; - @override String get dialogRemoveExtension => 'Eliminar extensión'; @@ -4267,11 +2995,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get snackbarViewQueue => 'Ver cola'; - @override - String snackbarFailedToLoad(String error) { - return 'Error al cargar: $error'; - } - @override String snackbarUrlCopied(String platform) { return 'URL $platform copiada al portapapeles'; @@ -4314,11 +3037,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get errorRateLimitedMessage => 'Demasiadas solicitudes. Por favor, espere un momento antes de buscar de nuevo.'; - @override - String errorFailedToLoad(String item) { - return 'Error al cargar $item'; - } - @override String get errorNoTracksFound => 'No se encontraron pistas'; @@ -4327,27 +3045,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'No se puede cargar $item: falta una fuente de extensión'; } - @override - String get statusQueued => 'En cola'; - - @override - String get statusDownloading => 'Descargando'; - - @override - String get statusFinalizing => 'Finalizando'; - - @override - String get statusCompleted => 'Completado'; - - @override - String get statusFailed => 'Error'; - - @override - String get statusSkipped => 'Omitido'; - - @override - String get statusPaused => 'Pausado'; - @override String get actionPause => 'Pausar'; @@ -4357,24 +3054,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get actionCancel => 'Cancelar'; - @override - String get actionStop => 'Detener'; - - @override - String get actionSelect => 'Seleccionar'; - @override String get actionSelectAll => 'Seleccionar Todo'; @override String get actionDeselect => 'Deseleccionar'; - @override - String get actionPaste => 'Pegar'; - - @override - String get actionImportCsv => 'Importar CSV'; - @override String get actionRemoveCredentials => 'Eliminar credenciales'; @@ -4389,20 +3074,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get selectionAllSelected => 'Todas las pistas seleccionadas'; - @override - String get selectionTapToSelect => 'Toca las pistas para seleccionar'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'pistas', - one: 'pista', - ); - return '¡Eliminar $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Seleccionar pistas a eliminar'; @@ -4429,43 +3100,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get tooltipPlay => 'Reproducir'; - @override - String get tooltipCancel => 'Cancelar'; - - @override - String get tooltipStop => 'Detener'; - - @override - String get tooltipRetry => 'Volver a intentar'; - - @override - String get tooltipRemove => 'Eliminar'; - - @override - String get tooltipClear => 'Borrar'; - - @override - String get tooltipPaste => 'Pegar'; - @override String get filenameFormat => 'Formato del nombre del archivo'; - @override - String filenameFormatPreview(String preview) { - return 'Vista previa: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Marcadores disponibles:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Organización de carpetas'; - @override String get folderOrganizationNone => 'Ninguna organización'; @@ -4501,20 +3138,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get updateAvailable => 'Actualización Disponible'; - @override - String updateNewVersion(String version) { - return 'Versión $version está disponible'; - } - - @override - String get updateDownload => 'Descargar'; - @override String get updateLater => 'Más tarde'; - @override - String get updateChangelog => 'Historial de cambios'; - @override String get updateStartingDownload => 'Iniciando descarga...'; @@ -4545,13 +3171,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get updateDontRemind => 'No recordar'; - @override - String get providerPriority => 'Prioridad del proveedor'; - - @override - String get providerPrioritySubtitle => - 'Arrastre para reordenar los proveedores de descarga'; - @override String get providerPriorityTitle => 'Prioridad del proveedor'; @@ -4569,13 +3188,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get providerExtension => 'Extensión'; - @override - String get metadataProviderPriority => 'Prioridad del proveedor de metadatos'; - - @override - String get metadataProviderPrioritySubtitle => - 'Orden usado al recuperar metadatos de la pista'; - @override String get metadataProviderPriorityTitle => 'Prioridad de los metadatos'; @@ -4596,18 +3208,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get logTitle => 'Registros'; - @override - String get logCopy => 'Copiar Registros'; - - @override - String get logClear => 'Limpiar registros'; - - @override - String get logShare => 'Compartir Registros'; - - @override - String get logEmpty => 'No hay registros aún'; - @override String get logCopied => 'Registros copiados al portapapeles'; @@ -4633,18 +3233,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get logClearLogsMessage => '¿Estás seguro que deseas limpiar todos los registros?'; - @override - String get logIspBlocking => 'BLOQUEO POR EL ISP DETECTADO'; - - @override - String get logRateLimited => 'TASA LIMITADA'; - - @override - String get logNetworkError => 'ERROR DE RED'; - - @override - String get logTrackNotFound => 'PISTA NO ENCONTRADA'; - @override String get logFilterBySeverity => 'Filtrar los registros por gravedad'; @@ -4655,48 +3243,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get logNoLogsYetSubtitle => 'Los registros aparecerán aquí mientras usas la aplicación'; - @override - String get logIssueSummary => 'Resumen de Incidencias'; - - @override - String get logIspBlockingDescription => - 'Tu ISP puede estar bloqueando el acceso a los servicios de descarga'; - - @override - String get logIspBlockingSuggestion => - 'Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Demasiadas solicitudes al servicio'; - - @override - String get logRateLimitedSuggestion => - 'Espere unos minutos antes de volver a intentarlo'; - - @override - String get logNetworkErrorDescription => 'Problemas de conexión detectados'; - - @override - String get logNetworkErrorSuggestion => 'Comprueba tu conexión a internet'; - - @override - String get logTrackNotFoundDescription => - 'No se pudieron encontrar algunas pistas en los servicios de descarga'; - - @override - String get logTrackNotFoundSuggestion => - 'La pista puede no estar disponible en calidad sin pérdida'; - - @override - String logTotalErrors(int count) { - return 'Total de errores: $count'; - } - - @override - String logAffected(String domains) { - return 'Afectado: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entradas ($count filtradas)'; @@ -4804,9 +3350,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get appearanceLanguage => 'Idioma de la aplicación'; - @override - String get appearanceLanguageSubtitle => 'Elija su idioma preferido'; - @override String get settingsAppearanceSubtitle => 'Tema, colores, pantalla'; @@ -4832,9 +3375,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get pressBackAgainToExit => 'Presione de nuevo para salir'; - @override - String get tracksHeader => 'Pistas'; - @override String downloadAllCount(int count) { return 'Descargar Todo ($count)'; @@ -4949,11 +3489,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get trackDeleteConfirmMessage => 'Esto eliminará permanentemente el archivo descargado y lo eliminará de tu historial.'; - @override - String trackCannotOpen(String message) { - return 'No se puede abrir: $message'; - } - @override String get dateToday => 'Hoy'; @@ -4975,18 +3510,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '$count meses atrás'; } - @override - String get concurrentSequential => 'Secuencial'; - - @override - String get concurrentParallel2 => '2 simultáneamente'; - - @override - String get concurrentParallel3 => '3 simultáneamente'; - - @override - String get tapToSeeError => 'Pulse para ver los detalles del error'; - @override String get storeFilterAll => 'Todo'; @@ -5008,15 +3531,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get storeClearFilters => 'Limpiar filtros'; - @override - String get storeNoResults => 'No se encontraron extensiones'; - - @override - String get extensionProviderPriority => 'Prioridad del proveedor'; - - @override - String get extensionInstallButton => 'Instalar extensión'; - @override String get extensionDefaultProvider => 'Por defecto (Deezer/Spotify)'; @@ -5170,39 +3684,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get qualityHiResFlacMaxSubtitle => '24 bits / hasta 192kHz'; - @override - String get qualityLossy => 'Con pérdidas'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (convertido desde FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (convertido de FLAC)'; - - @override - String get enableLossyOption => 'Habilitar opción con pérdida'; - - @override - String get enableLossyOptionSubtitleOn => - 'La opción de calidad con pérdida está disponible'; - - @override - String get enableLossyOptionSubtitleOff => - 'Descargas FLAC y luego se convierten en formato con pérdida'; - - @override - String get lossyFormat => 'Formato con Perdido'; - - @override - String get lossyFormatDescription => - 'Elegir el formato con pérdida para la conversión'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, mejor compatibilidad'; - - @override - String get lossyFormatOpusSubtitle => '128kbps, mejor calidad a menor tamaño'; - @override String get qualityNote => 'La calidad real depende de la disponibilidad de la pista del servicio'; @@ -5226,14 +3707,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -5245,80 +3718,18 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Guardar Formato'; - - @override - String get downloadSelectService => 'Seleccionar Servicio'; - @override String get downloadSelectQuality => 'Seleccionar Calidad'; @override String get downloadFrom => 'Descargar Desde'; - @override - String get downloadDefaultQualityLabel => 'Calidad por Defecto'; - - @override - String get downloadBestAvailable => 'La mejor disponible'; - - @override - String get folderNone => 'Ninguna'; - - @override - String get folderNoneSubtitle => - 'Guardar todos los archivos directamente para descargar la carpeta'; - - @override - String get folderArtist => 'Artista'; - - @override - String get folderArtistSubtitle => 'Nombre del Artista/nombre de archivo'; - - @override - String get folderAlbum => 'Álbum'; - - @override - String get folderAlbumSubtitle => 'Nombre del álbum/nombre de archivo'; - - @override - String get folderArtistAlbum => 'Artista/Álbum'; - - @override - String get folderArtistAlbumSubtitle => - 'Nombre del Artista/Nombre del Álbum/Nombre del Archivo'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Oscuro'; @override String get appearanceAmoledDarkSubtitle => 'Fondo negro puro'; - @override - String get appearanceChooseAccentColor => 'Elegir color principal'; - - @override - String get appearanceChooseTheme => 'Modo de tema'; - - @override - String get queueTitle => 'Descargas en proceso'; - @override String get queueClearAll => 'Eliminar todo'; @@ -5326,19 +3737,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get queueClearAllMessage => '¿Estás seguro de que quieres borrar todas las descargas?'; - @override - String get queueExportFailed => 'Exportar'; - - @override - String get queueExportFailedSuccess => - 'Descarga fallida exportada al archivo TXT'; - - @override - String get queueExportFailedClear => 'Limpieza Fallida'; - - @override - String get queueExportFailedError => 'Error al exportar descargas'; - @override String get settingsAutoExportFailed => 'Autoexportar descargas fallidas'; @@ -5359,30 +3757,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get settingsDownloadNetworkSubtitle => 'Elegir qué red usar para descargas. Cuando se establece en WiFi solamente, las descargas se detendrán en los datos móviles.'; - @override - String get queueEmpty => 'No hay descargas en cola'; - - @override - String get queueEmptySubtitle => 'Añadir pistas desde la pantalla de inicio'; - - @override - String get queueClearCompleted => 'Limpiar tareas finalizadas'; - - @override - String get queueDownloadFailed => 'Descarga fallida'; - - @override - String get queueTrackLabel => 'Pista:'; - - @override - String get queueArtistLabel => 'Artista:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Error desconocido'; - @override String get albumFolderArtistAlbum => 'Artista / Álbum'; @@ -5430,14 +3804,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '¿Eliminar $count $_temp0 del historial?\n\nEsto también eliminará los archivos del almacenamiento.'; } - @override - String get downloadedAlbumTracksHeader => 'Pistas'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count descargado'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count seleccionado'; @@ -5468,9 +3834,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'Disco $discNumber'; } - @override - String get utilityFunctions => 'Funciones de utilidad'; - @override String get recentTypeArtist => 'Artista'; @@ -5494,11 +3857,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'Lista de reproducción: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Descargar Discografía'; @@ -5604,9 +3962,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -5669,11 +4024,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -5753,21 +4103,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -5777,11 +4112,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -5807,72 +4137,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -5899,18 +4163,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -5918,18 +4170,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -5990,9 +4230,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -6155,10 +4392,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -6235,43 +4468,15 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get trackConvertFailed => 'Conversion failed'; @override - String get setupModeSelectionTitle => 'Elige tu modo'; + String downloadedAlbumDownloadedCount(int count) { + return '$count descargado'; + } @override - String get setupModeSelectionDescription => - '¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Descargador'; - - @override - String get setupModeDownloaderFeature1 => - 'Descarga pistas en calidad FLAC sin pérdida'; - - @override - String get setupModeDownloaderFeature2 => - 'Guarda música en tu dispositivo para escuchar sin conexión'; - - @override - String get setupModeDownloaderFeature3 => - 'Gestiona tu biblioteca de música local'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Transmite pistas al instante sin descargar'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue descubre automáticamente nueva música para ti'; - - @override - String get setupModeStreamingFeature3 => - 'Reproduce cualquier pista bajo demanda con controles de reproducción'; - - @override - String get setupModeChangeableLater => - 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 8c5febcf..adf0d48d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -11,19 +11,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.'; - @override String get navHome => 'Accueil'; @override String get navLibrary => 'Bibliothèques'; - @override - String get navHistory => 'Historique'; - @override String get navSettings => 'Paramètres'; @@ -33,14 +26,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get homeTitle => 'Accueil'; - @override - String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Rechercher avec $extensionName...'; - } - @override String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom'; @@ -50,17 +35,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get homeRecent => 'Récent'; - @override - String get historyTitle => 'Historique'; - - @override - String historyDownloading(int count) { - return 'Téléchargement ($count)'; - } - - @override - String get historyDownloaded => 'Téléchargé'; - @override String get historyFilterAll => 'Tous'; @@ -70,49 +44,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get historyFilterSingles => 'Titres'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Pas d\'historique de téléchargement'; - - @override - String get historyNoDownloadsSubtitle => - 'Les pistes téléchargées apparaîtront ici'; - - @override - String get historyNoAlbums => 'Pas de téléchargement d\'album'; - - @override - String get historyNoAlbumsSubtitle => - 'Téléchargez plusieurs titres d\'un album pour les voir ici'; - - @override - String get historyNoSingles => 'Pas de téléchargements uniques'; - - @override - String get historyNoSinglesSubtitle => - 'Les téléchargements de pistes uniques apparaîtront ici'; - @override String get historySearchHint => 'Historique de recherche...'; @@ -137,30 +68,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadTitle => 'Télécharger'; - @override - String get downloadLocation => 'Télécharger Localisation'; - - @override - String get downloadLocationSubtitle => - 'Choisissez où enregistrer des fichiers'; - - @override - String get downloadLocationDefault => 'Localisation par défaut'; - - @override - String get downloadDefaultService => 'Service par défaut'; - - @override - String get downloadDefaultServiceSubtitle => - 'Service utilisé pour les téléchargements'; - - @override - String get downloadDefaultQuality => 'Qualité par défaut'; - - @override - String get downloadAskQuality => - 'Demandez La Qualité Avant Le Téléchargement'; - @override String get downloadAskQualitySubtitle => 'Afficher le sélecteur de qualité pour chaque téléchargement'; @@ -171,31 +78,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadFolderOrganization => 'Organisation du dossier'; - @override - String get downloadSeparateSingles => 'Titres séparés'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Mettre des pistes uniques dans un dossier séparé'; - - @override - String get qualityBest => 'Meilleur Disponible'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Apparence'; - @override - String get appearanceTheme => 'Thème'; - @override String get appearanceThemeSystem => 'Système'; @@ -212,9 +97,6 @@ class AppLocalizationsFr extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Utilisez les couleurs de votre fond d\'écran'; - @override - String get appearanceAccentColor => 'Couleur d\'accent'; - @override String get appearanceHistoryView => 'Historique Vue'; @@ -227,9 +109,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Recherche Source'; - @override String get optionsPrimaryProvider => 'Fournisseur principal'; @@ -253,33 +132,6 @@ class AppLocalizationsFr extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Essayez d\'autres services si le téléchargement échoue'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Utiliser des fournisseurs d\'extension'; @@ -383,18 +235,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -411,9 +251,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get extensionsUninstall => 'Désinstaller'; - @override - String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche'; - @override String get storeTitle => 'Magasin d\'extension'; @@ -487,9 +324,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -508,13 +342,6 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -533,32 +360,6 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -568,17 +369,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -587,27 +377,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => ''; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -620,53 +389,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackMetadataDelete => 'Supprimer'; - @override - String get trackMetadataRedownload => 'Re-télécharger'; - - @override - String get trackMetadataOpenFolder => 'Dossier ouvert'; - - @override - String get setupTitle => 'Bienvenue chez SpotiFLAC'; - - @override - String get setupSubtitle => 'On va commencer'; - - @override - String get setupStoragePermission => 'Permission de stockage'; - - @override - String get setupStoragePermissionSubtitle => - 'Requis pour enregistrer les fichiers téléchargés'; - - @override - String get setupStoragePermissionGranted => 'Permission accordée'; - - @override - String get setupStoragePermissionDenied => 'Permission refusée'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -688,9 +419,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -732,21 +460,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -763,13 +476,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Dossier de téléchargement sélectionné!'; - @override String get setupFolderChoose => 'Choisissez le dossier pour télécharger'; @@ -777,48 +483,12 @@ class AppLocalizationsFr extends AppLocalizations { String get setupFolderDescription => 'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -826,32 +496,19 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -861,21 +518,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -898,28 +543,9 @@ class AppLocalizationsFr extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1020,11 +646,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1066,44 +687,14 @@ class AppLocalizationsFr extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1113,24 +704,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1145,20 +724,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1185,40 +750,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1226,9 +760,6 @@ class AppLocalizationsFr extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1263,20 +794,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1307,12 +827,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1330,13 +844,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1357,18 +864,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1393,18 +888,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1414,48 +897,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1562,9 +1003,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1586,19 +1024,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1707,11 +1137,6 @@ class AppLocalizationsFr extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1733,18 +1158,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1766,15 +1179,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1925,38 +1329,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1971,24 +1343,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -2004,14 +1358,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2023,78 +1369,18 @@ class AppLocalizationsFr extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2102,19 +1388,6 @@ class AppLocalizationsFr extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2135,30 +1408,6 @@ class AppLocalizationsFr extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2205,14 +1454,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2243,9 +1484,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2269,23 +1507,12 @@ class AppLocalizationsFr extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2330,9 +1557,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2388,9 +1612,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2453,11 +1674,6 @@ class AppLocalizationsFr extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2548,21 +1764,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2572,11 +1773,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2602,72 +1798,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2694,18 +1824,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2713,18 +1831,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2785,9 +1891,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2950,10 +2053,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3234,43 +2333,15 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Choisissez votre mode'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Téléchargeur'; - - @override - String get setupModeDownloaderFeature1 => - 'Téléchargez des pistes en qualité FLAC sans perte'; - - @override - String get setupModeDownloaderFeature2 => - 'Enregistrez de la musique sur votre appareil pour une écoute hors ligne'; - - @override - String get setupModeDownloaderFeature3 => - 'Gérez votre bibliothèque musicale locale'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Diffusez des pistes instantanément sans télécharger'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue découvre automatiquement de nouvelle musique pour vous'; - - @override - String get setupModeStreamingFeature3 => - 'Écoutez n\'importe quelle piste à la demande avec les contrôles de lecture'; - - @override - String get setupModeChangeableLater => - 'Vous pouvez changer de mode à tout moment dans les Paramètres.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 3c3388d9..50ca83db 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -11,19 +11,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get appName => 'SpotiFlac'; - @override - String get appDescription => - 'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।'; - @override String get navHome => 'होम'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'इतिहास'; - @override String get navSettings => 'विकल्प'; @@ -33,14 +26,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'दिखावट'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -248,33 +131,6 @@ class AppLocalizationsHi extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -377,18 +233,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -405,9 +249,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -481,9 +322,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -502,13 +340,6 @@ class AppLocalizationsHi extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -527,32 +358,6 @@ class AppLocalizationsHi extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -562,17 +367,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -581,27 +375,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -614,53 +387,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -682,9 +417,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -726,21 +458,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -757,13 +474,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -771,48 +481,12 @@ class AppLocalizationsHi extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -820,32 +494,19 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -855,21 +516,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -892,28 +541,9 @@ class AppLocalizationsHi extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1014,11 +644,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1060,44 +685,14 @@ class AppLocalizationsHi extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1107,24 +702,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1139,20 +722,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1179,40 +748,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1220,9 +758,6 @@ class AppLocalizationsHi extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1257,20 +792,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1301,12 +825,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1324,13 +842,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1351,18 +862,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1387,18 +886,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1408,48 +895,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1556,9 +1001,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1580,19 +1022,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1701,11 +1135,6 @@ class AppLocalizationsHi extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1727,18 +1156,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1760,15 +1177,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1919,38 +1327,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1965,24 +1341,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1998,14 +1356,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2017,78 +1367,18 @@ class AppLocalizationsHi extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2096,19 +1386,6 @@ class AppLocalizationsHi extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2129,30 +1406,6 @@ class AppLocalizationsHi extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2199,14 +1452,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2237,9 +1482,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2263,23 +1505,12 @@ class AppLocalizationsHi extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2324,9 +1555,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2382,9 +1610,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2447,11 +1672,6 @@ class AppLocalizationsHi extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2542,21 +1762,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2566,11 +1771,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2596,72 +1796,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2688,18 +1822,6 @@ class AppLocalizationsHi extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2707,18 +1829,6 @@ class AppLocalizationsHi extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2779,9 +1889,6 @@ class AppLocalizationsHi extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2944,10 +2051,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3228,43 +2331,15 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'अपना मोड चुनें'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'डाउनलोडर'; - - @override - String get setupModeDownloaderFeature1 => - 'लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें'; - - @override - String get setupModeDownloaderFeature2 => - 'ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें'; - - @override - String get setupModeDownloaderFeature3 => - 'अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें'; - - @override - String get setupModeStreamingTitle => 'स्ट्रीमिंग'; - - @override - String get setupModeStreamingFeature1 => - 'बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है'; - - @override - String get setupModeStreamingFeature3 => - 'प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं'; - - @override - String get setupModeChangeableLater => - 'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c687f715..ff3af0fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -11,19 +11,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; - @override String get navHome => 'Beranda'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'Riwayat'; - @override String get navSettings => 'Pengaturan'; @@ -33,14 +26,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeTitle => 'Beranda'; - @override - String get homeSearchHint => 'Tempel URL Spotify atau cari...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Cari dengan $extensionName...'; - } - @override String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama'; @@ -50,17 +35,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeRecent => 'Terbaru'; - @override - String get historyTitle => 'Riwayat'; - - @override - String historyDownloading(int count) { - return 'Mengunduh ($count)'; - } - - @override - String get historyDownloaded => 'Terunduh'; - @override String get historyFilterAll => 'Semua'; @@ -70,49 +44,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get historyFilterSingles => 'Single'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lagu', - one: '1 lagu', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count album', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Tidak ada riwayat unduhan'; - - @override - String get historyNoDownloadsSubtitle => - 'Lagu yang diunduh akan muncul di sini'; - - @override - String get historyNoAlbums => 'Tidak ada unduhan album'; - - @override - String get historyNoAlbumsSubtitle => - 'Unduh beberapa lagu dari album untuk melihatnya di sini'; - - @override - String get historyNoSingles => 'Tidak ada unduhan single'; - - @override - String get historyNoSinglesSubtitle => - 'Unduhan lagu satuan akan muncul di sini'; - @override String get historySearchHint => 'Search history...'; @@ -137,28 +68,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadTitle => 'Unduhan'; - @override - String get downloadLocation => 'Lokasi Unduhan'; - - @override - String get downloadLocationSubtitle => 'Pilih tempat menyimpan file'; - - @override - String get downloadLocationDefault => 'Lokasi default'; - - @override - String get downloadDefaultService => 'Layanan Default'; - - @override - String get downloadDefaultServiceSubtitle => - 'Layanan yang digunakan untuk unduhan'; - - @override - String get downloadDefaultQuality => 'Kualitas Default'; - - @override - String get downloadAskQuality => 'Tanya Kualitas Sebelum Unduh'; - @override String get downloadAskQualitySubtitle => 'Tampilkan pemilih kualitas untuk setiap unduhan'; @@ -169,31 +78,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadFolderOrganization => 'Organisasi Folder'; - @override - String get downloadSeparateSingles => 'Pisahkan Single'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Letakkan lagu satuan di folder terpisah'; - - @override - String get qualityBest => 'Terbaik'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Tampilan'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistem'; @@ -210,9 +97,6 @@ class AppLocalizationsId extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Gunakan warna dari wallpaper Anda'; - @override - String get appearanceAccentColor => 'Warna Aksen'; - @override String get appearanceHistoryView => 'Tampilan Riwayat'; @@ -225,9 +109,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get optionsTitle => 'Opsi'; - @override - String get optionsSearchSource => 'Sumber Pencarian'; - @override String get optionsPrimaryProvider => 'Provider Utama'; @@ -251,34 +132,6 @@ class AppLocalizationsId extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Coba layanan lain jika unduhan gagal'; - @override - String get optionsAutoSkipUnavailableTracks => - 'Lewati Otomatis Lagu yang Tidak Tersedia'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Berhenti di lagu yang gagal dan tampilkan pesan error.'; - - @override - String get optionsInteractionMode => 'Mode Interaksi'; - - @override - String get modeDownloader => 'Mode Downloader'; - - @override - String get modeDownloaderSubtitle => - 'Ketuk lagu untuk menambah ke antrean unduhan'; - - @override - String get modeStreaming => 'Mode Streaming'; - - @override - String get modeStreamingSubtitle => 'Ketuk lagu untuk langsung memutar'; - @override String get optionsUseExtensionProviders => 'Gunakan Provider Ekstensi'; @@ -382,18 +235,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get extensionsTitle => 'Ekstensi'; - @override - String get extensionsInstalled => 'Ekstensi Terpasang'; - - @override - String get extensionsNone => 'Tidak ada ekstensi terpasang'; - - @override - String get extensionsNoneSubtitle => 'Pasang ekstensi dari tab Toko'; - - @override - String get extensionsEnabled => 'Aktif'; - @override String get extensionsDisabled => 'Nonaktif'; @@ -410,9 +251,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get extensionsUninstall => 'Copot'; - @override - String get extensionsSetAsSearch => 'Jadikan Provider Pencarian'; - @override String get storeTitle => 'Toko Ekstensi'; @@ -487,9 +325,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Dukungan'; - @override String get aboutApp => 'Aplikasi'; @@ -508,13 +343,6 @@ class AppLocalizationsId extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -533,32 +361,6 @@ class AppLocalizationsId extends AppLocalizations { String get aboutAppDescription => 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lagu', - one: '1 lagu', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Unduh Semua'; - - @override - String get albumDownloadRemaining => 'Unduh Sisanya'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artis'; - @override String get artistAlbums => 'Album'; @@ -568,17 +370,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get artistCompilations => 'Kompilasi'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count rilis', - one: '1 rilis', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Populer'; @@ -587,27 +378,6 @@ class AppLocalizationsId extends AppLocalizations { return '$count pendengar bulanan'; } - @override - String get trackMetadataTitle => 'Info Lagu'; - - @override - String get trackMetadataArtist => 'Artis'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Durasi'; - - @override - String get trackMetadataQuality => 'Kualitas'; - - @override - String get trackMetadataPath => 'Lokasi File'; - - @override - String get trackMetadataDownloadedAt => 'Diunduh'; - @override String get trackMetadataService => 'Layanan'; @@ -620,53 +390,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackMetadataDelete => 'Hapus'; - @override - String get trackMetadataRedownload => 'Unduh ulang'; - - @override - String get trackMetadataOpenFolder => 'Buka Folder'; - - @override - String get setupTitle => 'Selamat Datang di SpotiFLAC'; - - @override - String get setupSubtitle => 'Mari mulai pengaturan'; - - @override - String get setupStoragePermission => 'Izin Penyimpanan'; - - @override - String get setupStoragePermissionSubtitle => - 'Diperlukan untuk menyimpan file unduhan'; - - @override - String get setupStoragePermissionGranted => 'Izin diberikan'; - - @override - String get setupStoragePermissionDenied => 'Izin ditolak'; - @override String get setupGrantPermission => 'Berikan Izin'; - @override - String get setupDownloadLocation => 'Lokasi Unduhan'; - - @override - String get setupChooseFolder => 'Pilih Folder'; - - @override - String get setupContinue => 'Lanjutkan'; - @override String get setupSkip => 'Lewati untuk sekarang'; @override String get setupStorageAccessRequired => 'Akses Penyimpanan Diperlukan'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.'; @@ -688,9 +420,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Izin $permissionType diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.'; } - @override - String get setupSelectDownloadFolder => 'Pilih Folder Unduhan'; - @override String get setupUseDefaultFolder => 'Gunakan Folder Default?'; @@ -732,21 +461,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC'; - @override - String get setupStepStorage => 'Penyimpanan'; - - @override - String get setupStepNotification => 'Notifikasi'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Izin'; - @override String get setupStorageGranted => 'Izin Penyimpanan Diberikan!'; @@ -763,13 +477,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupNotificationEnable => 'Aktifkan Notifikasi'; - @override - String get setupNotificationDescription => - 'Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.'; - - @override - String get setupFolderSelected => 'Folder Unduhan Dipilih!'; - @override String get setupFolderChoose => 'Pilih Folder Unduhan'; @@ -777,49 +484,12 @@ class AppLocalizationsId extends AppLocalizations { String get setupFolderDescription => 'Pilih folder tempat musik yang diunduh akan disimpan.'; - @override - String get setupChangeFolder => 'Ubah Folder'; - @override String get setupSelectFolder => 'Pilih Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Opsional)'; - - @override - String get setupSpotifyApiDescription => - 'Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.'; - - @override - String get setupUseSpotifyApi => 'Gunakan Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Masukkan kredensial Anda di bawah'; - - @override - String get setupUsingDeezer => 'Menggunakan Deezer (tidak perlu akun)'; - - @override - String get setupEnterClientId => 'Masukkan Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Masukkan Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Dapatkan kredensial API gratis dari Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Aktifkan Notifikasi'; - @override - String get setupProceedToNextStep => - 'Anda dapat melanjutkan ke langkah berikutnya.'; - - @override - String get setupNotificationProgressDescription => - 'Anda akan menerima notifikasi progres unduhan.'; - @override String get setupNotificationBackgroundDescription => 'Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.'; @@ -827,32 +497,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupSkipForNow => 'Lewati untuk sekarang'; - @override - String get setupBack => 'Kembali'; - @override String get setupNext => 'Lanjut'; @override String get setupGetStarted => 'Mulai'; - @override - String get setupSkipAndStart => 'Lewati & Mulai'; - @override String get setupAllowAccessToManageFiles => 'Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.'; - @override - String get setupGetCredentialsFromSpotify => - 'Dapatkan kredensial dari developer.spotify.com'; - @override String get dialogCancel => 'Batal'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Simpan'; @@ -862,21 +519,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get dialogRetry => 'Coba Lagi'; - @override - String get dialogClose => 'Tutup'; - - @override - String get dialogYes => 'Ya'; - - @override - String get dialogNo => 'Tidak'; - @override String get dialogClear => 'Hapus'; - @override - String get dialogConfirm => 'Konfirmasi'; - @override String get dialogDone => 'Selesai'; @@ -899,28 +544,9 @@ class AppLocalizationsId extends AppLocalizations { String get dialogUnsavedChanges => 'Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?'; - @override - String get dialogDownloadFailed => 'Unduhan Gagal'; - - @override - String get dialogTrackLabel => 'Lagu:'; - - @override - String get dialogArtistLabel => 'Artis:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Hapus Semua'; - @override - String get dialogClearAllDownloads => - 'Apakah Anda yakin ingin menghapus semua unduhan?'; - - @override - String get dialogRemoveFromDevice => 'Hapus dari perangkat?'; - @override String get dialogRemoveExtension => 'Hapus Ekstensi'; @@ -1021,11 +647,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get snackbarViewQueue => 'Lihat Antrian'; - @override - String snackbarFailedToLoad(String error) { - return 'Gagal memuat: $error'; - } - @override String snackbarUrlCopied(String platform) { return 'URL $platform disalin ke clipboard'; @@ -1067,44 +688,14 @@ class AppLocalizationsId extends AppLocalizations { String get errorRateLimitedMessage => 'Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.'; - @override - String errorFailedToLoad(String item) { - return 'Gagal memuat $item'; - } - @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; - @override - String get errorSeekNotSupported => - 'Menggeser posisi lagu tidak didukung untuk live stream ini'; - @override String errorMissingExtensionSource(String item) { return 'Tidak dapat memuat $item: sumber ekstensi tidak ada'; } - @override - String get statusQueued => 'Mengantri'; - - @override - String get statusDownloading => 'Mengunduh'; - - @override - String get statusFinalizing => 'Menyelesaikan'; - - @override - String get statusCompleted => 'Selesai'; - - @override - String get statusFailed => 'Gagal'; - - @override - String get statusSkipped => 'Dilewati'; - - @override - String get statusPaused => 'Dijeda'; - @override String get actionPause => 'Jeda'; @@ -1114,24 +705,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get actionCancel => 'Batal'; - @override - String get actionStop => 'Hentikan'; - - @override - String get actionSelect => 'Pilih'; - @override String get actionSelectAll => 'Pilih Semua'; @override String get actionDeselect => 'Batal Pilih'; - @override - String get actionPaste => 'Tempel'; - - @override - String get actionImportCsv => 'Impor CSV'; - @override String get actionRemoveCredentials => 'Hapus Kredensial'; @@ -1146,20 +725,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get selectionAllSelected => 'Semua lagu dipilih'; - @override - String get selectionTapToSelect => 'Ketuk lagu untuk memilih'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'lagu', - one: 'lagu', - ); - return 'Hapus $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Pilih lagu untuk dihapus'; @@ -1186,40 +751,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get tooltipPlay => 'Putar'; - @override - String get tooltipCancel => 'Batal'; - - @override - String get tooltipStop => 'Hentikan'; - - @override - String get tooltipRetry => 'Coba Lagi'; - - @override - String get tooltipRemove => 'Hapus'; - - @override - String get tooltipClear => 'Hapus'; - - @override - String get tooltipPaste => 'Tempel'; - @override String get filenameFormat => 'Format Nama File'; - @override - String filenameFormatPreview(String preview) { - return 'Pratinjau: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Placeholder yang tersedia:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan'; @@ -1227,9 +761,6 @@ class AppLocalizationsId extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Aktifkan tag format untuk padding nomor lagu dan pola tanggal'; - @override - String get folderOrganization => 'Organisasi Folder'; - @override String get folderOrganizationNone => 'Tidak ada'; @@ -1264,20 +795,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get updateAvailable => 'Pembaruan Tersedia'; - @override - String updateNewVersion(String version) { - return 'Versi $version tersedia'; - } - - @override - String get updateDownload => 'Unduh'; - @override String get updateLater => 'Nanti'; - @override - String get updateChangelog => 'Log Perubahan'; - @override String get updateStartingDownload => 'Memulai unduhan...'; @@ -1308,13 +828,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get updateDontRemind => 'Jangan ingatkan'; - @override - String get providerPriority => 'Prioritas Provider'; - - @override - String get providerPrioritySubtitle => - 'Seret untuk mengatur ulang provider unduhan'; - @override String get providerPriorityTitle => 'Prioritas Provider'; @@ -1332,13 +845,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get providerExtension => 'Ekstensi'; - @override - String get metadataProviderPriority => 'Prioritas Provider Metadata'; - - @override - String get metadataProviderPrioritySubtitle => - 'Urutan yang digunakan saat mengambil metadata lagu'; - @override String get metadataProviderPriorityTitle => 'Prioritas Metadata'; @@ -1359,18 +865,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get logTitle => 'Log'; - @override - String get logCopy => 'Salin Log'; - - @override - String get logClear => 'Hapus Log'; - - @override - String get logShare => 'Bagikan Log'; - - @override - String get logEmpty => 'Belum ada log'; - @override String get logCopied => 'Log disalin ke clipboard'; @@ -1396,18 +890,6 @@ class AppLocalizationsId extends AppLocalizations { String get logClearLogsMessage => 'Apakah Anda yakin ingin menghapus semua log?'; - @override - String get logIspBlocking => 'PEMBLOKIRAN ISP TERDETEKSI'; - - @override - String get logRateLimited => 'DIBATASI'; - - @override - String get logNetworkError => 'ERROR JARINGAN'; - - @override - String get logTrackNotFound => 'LAGU TIDAK DITEMUKAN'; - @override String get logFilterBySeverity => 'Filter log berdasarkan tingkat keparahan'; @@ -1418,49 +900,6 @@ class AppLocalizationsId extends AppLocalizations { String get logNoLogsYetSubtitle => 'Log akan muncul di sini saat Anda menggunakan aplikasi'; - @override - String get logIssueSummary => 'Ringkasan Masalah'; - - @override - String get logIspBlockingDescription => - 'ISP Anda mungkin memblokir akses ke layanan unduhan'; - - @override - String get logIspBlockingSuggestion => - 'Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8'; - - @override - String get logRateLimitedDescription => - 'Terlalu banyak permintaan ke layanan'; - - @override - String get logRateLimitedSuggestion => - 'Tunggu beberapa menit sebelum mencoba lagi'; - - @override - String get logNetworkErrorDescription => 'Masalah koneksi terdeteksi'; - - @override - String get logNetworkErrorSuggestion => 'Periksa koneksi internet Anda'; - - @override - String get logTrackNotFoundDescription => - 'Beberapa lagu tidak dapat ditemukan di layanan unduhan'; - - @override - String get logTrackNotFoundSuggestion => - 'Lagu mungkin tidak tersedia dalam kualitas lossless'; - - @override - String logTotalErrors(int count) { - return 'Total error: $count'; - } - - @override - String logAffected(String domains) { - return 'Terpengaruh: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entri ($count difilter)'; @@ -1567,9 +1006,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get appearanceLanguage => 'Bahasa Aplikasi'; - @override - String get appearanceLanguageSubtitle => 'Pilih bahasa yang kamu inginkan'; - @override String get settingsAppearanceSubtitle => 'Tema, warna, tampilan'; @@ -1591,19 +1027,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get pressBackAgainToExit => 'Tekan kembali sekali lagi untuk keluar'; - @override - String get tracksHeader => 'Lagu'; - @override String downloadAllCount(int count) { return 'Unduh Semua ($count)'; } - @override - String playAllCount(int count) { - return 'Putar Semua ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1712,11 +1140,6 @@ class AppLocalizationsId extends AppLocalizations { String get trackDeleteConfirmMessage => 'Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.'; - @override - String trackCannotOpen(String message) { - return 'Tidak dapat membuka: $message'; - } - @override String get dateToday => 'Hari ini'; @@ -1738,18 +1161,6 @@ class AppLocalizationsId extends AppLocalizations { return '$count bulan lalu'; } - @override - String get concurrentSequential => 'Berurutan'; - - @override - String get concurrentParallel2 => '2 Paralel'; - - @override - String get concurrentParallel3 => '3 Paralel'; - - @override - String get tapToSeeError => 'Ketuk untuk melihat detail error'; - @override String get storeFilterAll => 'Semua'; @@ -1771,15 +1182,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get storeClearFilters => 'Hapus filter'; - @override - String get storeNoResults => 'Tidak ada ekstensi ditemukan'; - - @override - String get extensionProviderPriority => 'Prioritas Provider'; - - @override - String get extensionInstallButton => 'Pasang Ekstensi'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1932,38 +1334,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; @@ -1978,24 +1348,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Masukkan bitrate manual ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate harus antara $min dan $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; @@ -2011,14 +1363,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2030,79 +1374,18 @@ class AppLocalizationsId extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Simpan Format'; - - @override - String get downloadSelectService => 'Pilih Layanan'; - @override String get downloadSelectQuality => 'Pilih Kualitas'; @override String get downloadFrom => 'Unduh Dari'; - @override - String get downloadDefaultQualityLabel => 'Kualitas Default'; - - @override - String get downloadBestAvailable => 'Terbaik tersedia'; - - @override - String get folderNone => 'Tidak ada'; - - @override - String get folderNoneSubtitle => - 'Simpan semua file langsung ke folder unduhan'; - - @override - String get folderArtist => 'Artis'; - - @override - String get folderArtistSubtitle => 'Nama Artis/namafile'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Nama Album/namafile'; - - @override - String get folderArtistAlbum => 'Artis/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Nama Artis/Nama Album/namafile'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Gelap'; @override String get appearanceAmoledDarkSubtitle => 'Latar belakang hitam murni'; - @override - String get appearanceChooseAccentColor => 'Pilih Warna Aksen'; - - @override - String get appearanceChooseTheme => 'Mode Tema'; - - @override - String get queueTitle => 'Antrian Unduhan'; - @override String get queueClearAll => 'Hapus Semua'; @@ -2110,19 +1393,6 @@ class AppLocalizationsId extends AppLocalizations { String get queueClearAllMessage => 'Apakah Anda yakin ingin menghapus semua unduhan?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2143,30 +1413,6 @@ class AppLocalizationsId extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'Tidak ada unduhan dalam antrian'; - - @override - String get queueEmptySubtitle => 'Tambahkan lagu dari layar beranda'; - - @override - String get queueClearCompleted => 'Hapus yang selesai'; - - @override - String get queueDownloadFailed => 'Unduhan Gagal'; - - @override - String get queueTrackLabel => 'Lagu:'; - - @override - String get queueArtistLabel => 'Artis:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Error tidak diketahui'; - @override String get albumFolderArtistAlbum => 'Artis / Album'; @@ -2213,14 +1459,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Hapus $count $_temp0 dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.'; } - @override - String get downloadedAlbumTracksHeader => 'Lagu'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count diunduh'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count dipilih'; @@ -2251,9 +1489,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Fungsi Utilitas'; - @override String get recentTypeArtist => 'Artis'; @@ -2277,23 +1512,12 @@ class AppLocalizationsId extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Putar Diskografi'; - @override String get discographyDownloadAll => 'Unduh Semua'; - @override - String get discographyPlayAll => 'Putar Semua'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2338,9 +1562,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Putar Terpilih'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2396,9 +1617,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2461,11 +1679,6 @@ class AppLocalizationsId extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2556,21 +1769,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2580,11 +1778,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2610,72 +1803,6 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2702,18 +1829,6 @@ class AppLocalizationsId extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2721,18 +1836,6 @@ class AppLocalizationsId extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2793,9 +1896,6 @@ class AppLocalizationsId extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2958,10 +2058,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3243,43 +2339,15 @@ class AppLocalizationsId extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Pilih Mode Anda'; + String downloadedAlbumDownloadedCount(int count) { + return '$count diunduh'; + } @override - String get setupModeSelectionDescription => - 'Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Pengunduh'; - - @override - String get setupModeDownloaderFeature1 => - 'Unduh trek dalam kualitas FLAC lossless'; - - @override - String get setupModeDownloaderFeature2 => - 'Simpan musik ke perangkat Anda untuk mendengarkan offline'; - - @override - String get setupModeDownloaderFeature3 => - 'Kelola perpustakaan musik lokal Anda'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Streaming trek secara instan tanpa mengunduh'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue secara otomatis menemukan musik baru untuk Anda'; - - @override - String get setupModeStreamingFeature3 => - 'Putar trek apa pun sesuai permintaan dengan kontrol pemutaran'; - - @override - String get setupModeChangeableLater => - 'Anda dapat beralih antar mode kapan saja di Pengaturan.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 78b99777..b473562c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -11,19 +11,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; - @override String get navHome => 'ホーム'; @override String get navLibrary => 'Library'; - @override - String get navHistory => '履歴'; - @override String get navSettings => '設定'; @@ -33,14 +26,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeTitle => 'ホーム'; - @override - String get homeSearchHint => 'Spotify の URL を貼り付けまたは検索...'; - - @override - String homeSearchHintExtension(String extensionName) { - return '$extensionName で検索...'; - } - @override String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します'; @@ -50,17 +35,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeRecent => '最近'; - @override - String get historyTitle => '履歴'; - - @override - String historyDownloading(int count) { - return 'ダウンロード中 ($count)'; - } - - @override - String get historyDownloaded => 'ダウンロード済み'; - @override String get historyFilterAll => 'すべて'; @@ -70,48 +44,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get historyFilterSingles => 'シングル'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のトラック', - one: '1 個のトラック', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のアルバム', - one: '1 個のアルバム', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'ダウンロード履歴はありません'; - - @override - String get historyNoDownloadsSubtitle => 'ダウンロードしたトラックはここに表示されます'; - - @override - String get historyNoAlbums => 'アルバムのダウンロードはありません'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'シングルのダウンロードはありません'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => '検索履歴...'; @@ -136,27 +68,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadTitle => 'ダウンロード'; - @override - String get downloadLocation => 'ダウンロード先'; - - @override - String get downloadLocationSubtitle => 'ファイルの保存先を選択'; - - @override - String get downloadLocationDefault => 'デフォルトの場所'; - - @override - String get downloadDefaultService => 'デフォルトのサービス'; - - @override - String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス'; - - @override - String get downloadDefaultQuality => 'デフォルトの品質'; - - @override - String get downloadAskQuality => 'ダウンロード前に品質を確認する'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadFolderOrganization => 'フォルダ構成'; - @override - String get downloadSeparateSingles => 'シングルを分割'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'おすすめ'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => '外観'; - @override - String get appearanceTheme => 'テーマ'; - @override String get appearanceThemeSystem => 'システム'; @@ -207,9 +96,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => '壁紙の色を使用する'; - @override - String get appearanceAccentColor => 'アクセントカラー'; - @override String get appearanceHistoryView => '履歴の表示'; @@ -222,9 +108,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get optionsTitle => 'オプション'; - @override - String get optionsSearchSource => '検索ソース'; - @override String get optionsPrimaryProvider => 'プライマリーのプロバイダー'; @@ -248,33 +131,6 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する'; @@ -374,18 +230,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get extensionsTitle => '拡張'; - @override - String get extensionsInstalled => 'インストール済みの拡張'; - - @override - String get extensionsNone => '拡張はインストールされていません'; - - @override - String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール'; - - @override - String get extensionsEnabled => '有効'; - @override String get extensionsDisabled => '無効'; @@ -402,9 +246,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get extensionsUninstall => 'アンインストール'; - @override - String get extensionsSetAsSearch => '検索のプロバイダーを設定'; - @override String get storeTitle => '拡張ストア'; @@ -477,9 +318,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get aboutSocial => 'ソーシャル'; - @override - String get aboutSupport => 'サポート'; - @override String get aboutApp => 'アプリ'; @@ -498,13 +336,6 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -523,32 +354,6 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutAppDescription => 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; - @override - String get albumTitle => 'アルバム'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のトラック', - one: '1 個のトラック', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'すべてダウンロード'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'プレイリスト'; - - @override - String get artistTitle => 'アーティスト'; - @override String get artistAlbums => 'アルバム'; @@ -558,17 +363,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get artistCompilations => 'コンピレーション'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のリリース', - one: '1 個のリリース', - ); - return '$_temp0'; - } - @override String get artistPopular => '人気'; @@ -577,27 +371,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$count 人の月間リスナー'; } - @override - String get trackMetadataTitle => 'トラック情報'; - - @override - String get trackMetadataArtist => 'アーティスト'; - - @override - String get trackMetadataAlbum => 'アルバム'; - - @override - String get trackMetadataDuration => '再生時間'; - - @override - String get trackMetadataQuality => '品質'; - - @override - String get trackMetadataPath => 'ファイルパス'; - - @override - String get trackMetadataDownloadedAt => 'ダウンロード済み'; - @override String get trackMetadataService => 'サービス'; @@ -610,52 +383,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackMetadataDelete => '削除'; - @override - String get trackMetadataRedownload => '再ダウンロード'; - - @override - String get trackMetadataOpenFolder => 'フォルダを開く'; - - @override - String get setupTitle => 'SpotiFLAC へようこそ'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'ストレージの権限'; - - @override - String get setupStoragePermissionSubtitle => 'ダウンロードしたファイルを保存するために必要です'; - - @override - String get setupStoragePermissionGranted => '権限を許可しました'; - - @override - String get setupStoragePermissionDenied => '権限が拒否されました'; - @override String get setupGrantPermission => '権限を許可'; - @override - String get setupDownloadLocation => 'ダウンロード先'; - - @override - String get setupChooseFolder => 'フォルダを選択'; - - @override - String get setupContinue => '続行'; - @override String get setupSkip => '今はスキップ'; @override String get setupStorageAccessRequired => 'ストレージアクセスが必要です'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -677,9 +413,6 @@ class AppLocalizationsJa extends AppLocalizations { return '最適な体験を得るには $permissionType の権限が必要です。この権限は設定で後から変更できます。'; } - @override - String get setupSelectDownloadFolder => 'ダウンロードフォルダを選択'; - @override String get setupUseDefaultFolder => 'デフォルトのフォルダを使用しますか?'; @@ -721,21 +454,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード'; - @override - String get setupStepStorage => 'ストレージ'; - - @override - String get setupStepNotification => '通知'; - - @override - String get setupStepFolder => 'フォルダ'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => '権限'; - @override String get setupStorageGranted => 'ストレージの権限が許可されました!'; @@ -752,13 +470,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupNotificationEnable => '通知を有効化する'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!'; - @override String get setupFolderChoose => 'ダウンロードフォルダを選択'; @@ -766,48 +477,12 @@ class AppLocalizationsJa extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'フォルダを変更'; - @override String get setupSelectFolder => 'フォルダを選択'; - @override - String get setupSpotifyApiOptional => 'Spotify API (任意)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Spotify API を使用する'; - - @override - String get setupEnterCredentialsBelow => '以下に認証情報を入力してください'; - - @override - String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)'; - - @override - String get setupEnterClientId => 'Spotify クライアント ID を入力'; - - @override - String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力'; - - @override - String get setupGetFreeCredentials => - 'Spotify 開発者ダッシュボードから無料の API 認証情報を取得します。'; - @override String get setupEnableNotifications => '通知を有効化する'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -815,32 +490,19 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupSkipForNow => '今はスキップ'; - @override - String get setupBack => '戻る'; - @override String get setupNext => '次へ'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'スキップと開始'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'developer.spotify.com から認証情報を取得します'; - @override String get dialogCancel => 'キャンセル'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => '保存'; @@ -850,21 +512,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get dialogRetry => '再試行'; - @override - String get dialogClose => '閉じる'; - - @override - String get dialogYes => 'はい'; - - @override - String get dialogNo => 'いいえ'; - @override String get dialogClear => '消去'; - @override - String get dialogConfirm => '続行'; - @override String get dialogDone => '完了'; @@ -887,28 +537,9 @@ class AppLocalizationsJa extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'ダウンロードに失敗しました'; - - @override - String get dialogTrackLabel => 'トラック:'; - - @override - String get dialogArtistLabel => 'アーティスト:'; - - @override - String get dialogErrorLabel => 'エラー:'; - @override String get dialogClearAll => 'すべて消去'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'デバイスから削除しますか?'; - @override String get dialogRemoveExtension => '拡張を削除'; @@ -1009,11 +640,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get snackbarViewQueue => 'キューを表示'; - @override - String snackbarFailedToLoad(String error) { - return '読み込みに失敗: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform の URL をクリップボードにコピーしました'; @@ -1054,44 +680,14 @@ class AppLocalizationsJa extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'トラックがありません'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return '$item を読み込めません: 拡張ソースがありません'; } - @override - String get statusQueued => 'キュー済み'; - - @override - String get statusDownloading => 'ダウンロード中'; - - @override - String get statusFinalizing => '終了処理中'; - - @override - String get statusCompleted => '完了しました'; - - @override - String get statusFailed => '失敗しました'; - - @override - String get statusSkipped => 'スキップしました'; - - @override - String get statusPaused => '一時停止中'; - @override String get actionPause => '一時停止'; @@ -1101,24 +697,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get actionCancel => 'キャンセル'; - @override - String get actionStop => '停止'; - - @override - String get actionSelect => '選択'; - @override String get actionSelectAll => 'すべて選択'; @override String get actionDeselect => '選択を解除'; - @override - String get actionPaste => '貼り付け'; - - @override - String get actionImportCsv => 'CSV をインポート'; - @override String get actionRemoveCredentials => '認証情報を削除'; @@ -1133,20 +717,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get selectionAllSelected => 'すべてのトラックを選択済み'; - @override - String get selectionTapToSelect => 'トラックをタップで選択'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '個のトラック', - one: '個のトラック', - ); - return '$count $_temp0を削除'; - } - @override String get selectionSelectToDelete => 'トラックを選択で削除'; @@ -1173,40 +743,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tooltipPlay => '再生'; - @override - String get tooltipCancel => 'キャンセル'; - - @override - String get tooltipStop => '停止'; - - @override - String get tooltipRetry => '再試行'; - - @override - String get tooltipRemove => '削除'; - - @override - String get tooltipClear => '消去'; - - @override - String get tooltipPaste => '貼り付け'; - @override String get filenameFormat => 'ファイル名の形式'; - @override - String filenameFormatPreview(String preview) { - return 'プレビュー: $preview'; - } - - @override - String get filenameAvailablePlaceholders => '利用可能なプレースホルダー:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1214,9 +753,6 @@ class AppLocalizationsJa extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'フォルダ構成'; - @override String get folderOrganizationNone => '構成がありません'; @@ -1250,20 +786,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get updateAvailable => '更新が利用可能です'; - @override - String updateNewVersion(String version) { - return 'バージョン $version が利用可能です'; - } - - @override - String get updateDownload => 'ダウンロード'; - @override String get updateLater => '後で'; - @override - String get updateChangelog => '更新履歴'; - @override String get updateStartingDownload => 'ダウンロードを開始中...'; @@ -1294,12 +819,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get updateDontRemind => '通知しない'; - @override - String get providerPriority => 'プロバイダーの優先度'; - - @override - String get providerPrioritySubtitle => 'ドラッグでダウンロードプロバイダーを並べ替え'; - @override String get providerPriorityTitle => 'プロバイダーの優先度'; @@ -1317,13 +836,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get providerExtension => '拡張'; - @override - String get metadataProviderPriority => 'メタデータプロバイダーの優先度'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'メタデータの優先度'; @@ -1344,18 +856,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get logTitle => 'ログ'; - @override - String get logCopy => 'ログをコピー'; - - @override - String get logClear => 'ログを消去'; - - @override - String get logShare => 'ログを共有'; - - @override - String get logEmpty => 'まだログはありません'; - @override String get logCopied => 'ログをクリップボードにコピーしました'; @@ -1380,18 +880,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get logClearLogsMessage => 'すべてのログを消去してもよろしいですか?'; - @override - String get logIspBlocking => 'ISP のブロックを検出しました'; - - @override - String get logRateLimited => 'レート制限'; - - @override - String get logNetworkError => 'ネットワークエラー'; - - @override - String get logTrackNotFound => 'トラックがありません'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1401,48 +889,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => '問題の概要'; - - @override - String get logIspBlockingDescription => - 'ISP がダウンロードサービスのアクセスをブロックしている可能性があります'; - - @override - String get logIspBlockingSuggestion => - 'VPN を使用するか DNS を 1.1.1.1 または 8.8.8.8 に変更をお試しください'; - - @override - String get logRateLimitedDescription => 'サービスへのリクエストが多すぎます'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => '接続の問題が検出されました'; - - @override - String get logNetworkErrorSuggestion => 'インターネット接続を確認してください'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'エラーの合計: $count'; - } - - @override - String logAffected(String domains) { - return '影響: $domains'; - } - @override String logEntriesFiltered(int count) { return 'エントリー ($count 個をフィルター済み)'; @@ -1549,9 +995,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appearanceLanguage => 'アプリの言語'; - @override - String get appearanceLanguageSubtitle => 'お好みの言語を選択してください'; - @override String get settingsAppearanceSubtitle => 'テーマ、カラー、画面'; @@ -1573,19 +1016,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'トラック'; - @override String downloadAllCount(int count) { return 'すべてダウンロード ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1694,11 +1129,6 @@ class AppLocalizationsJa extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return '開けません: $message'; - } - @override String get dateToday => '今日'; @@ -1720,18 +1150,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$count ヶ月前'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 並列'; - - @override - String get concurrentParallel3 => '3 並列'; - - @override - String get tapToSeeError => 'タップでエラーの詳細を表示'; - @override String get storeFilterAll => 'すべて'; @@ -1753,15 +1171,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storeClearFilters => 'フィルターを消去'; - @override - String get storeNoResults => '拡張がありません'; - - @override - String get extensionProviderPriority => 'プロバイダーの優先度'; - - @override - String get extensionInstallButton => '拡張をインストール'; - @override String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)'; @@ -1908,38 +1317,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; @@ -1953,24 +1330,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; @@ -1986,14 +1345,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2005,97 +1356,24 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => '形式を保存'; - - @override - String get downloadSelectService => 'サービスを選択'; - @override String get downloadSelectQuality => '品質を選択'; @override String get downloadFrom => 'ダウンロード元'; - @override - String get downloadDefaultQualityLabel => 'デフォルトの品質'; - - @override - String get downloadBestAvailable => 'おすすめ'; - - @override - String get folderNone => 'なし'; - - @override - String get folderNoneSubtitle => 'すべてのファイルをダウンロードフォルダに保存します'; - - @override - String get folderArtist => 'アーティスト'; - - @override - String get folderArtistSubtitle => 'アーティスト名/ファイル名'; - - @override - String get folderAlbum => 'アルバム'; - - @override - String get folderAlbumSubtitle => 'アルバム名/ファイル名'; - - @override - String get folderArtistAlbum => 'アーティスト/アルバム'; - - @override - String get folderArtistAlbumSubtitle => 'アーティスト名/アルバム名/ファイル名'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED ダーク'; @override String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景'; - @override - String get appearanceChooseAccentColor => 'アクセントカラーを選択'; - - @override - String get appearanceChooseTheme => 'テーマモード'; - - @override - String get queueTitle => 'ダウンロードキュー'; - @override String get queueClearAll => 'すべて消去'; @override String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2116,30 +1394,6 @@ class AppLocalizationsJa extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'キューにダウンロードがありません'; - - @override - String get queueEmptySubtitle => 'ホーム画面からトラックを追加'; - - @override - String get queueClearCompleted => '完了済みを消去'; - - @override - String get queueDownloadFailed => 'ダウンロードに失敗しました'; - - @override - String get queueTrackLabel => 'トラック:'; - - @override - String get queueArtistLabel => 'アーティスト:'; - - @override - String get queueErrorLabel => 'エラー:'; - - @override - String get queueUnknownError => '不明なエラー'; - @override String get albumFolderArtistAlbum => 'アーティスト / アルバム'; @@ -2185,14 +1439,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'トラック'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count 個をダウンロード済み'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count 個を選択済み'; @@ -2223,9 +1469,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'ディスク $discNumber'; } - @override - String get utilityFunctions => 'ユーティリティ機能'; - @override String get recentTypeArtist => 'アーティスト'; @@ -2249,23 +1492,12 @@ class AppLocalizationsJa extends AppLocalizations { return 'プレイリスト: $name'; } - @override - String errorGeneric(String message) { - return 'エラー: $message'; - } - @override String get discographyDownload => 'ディスコグラフィをダウンロード'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'すべてダウンロード'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$albumCount 個のリリースから $count 個のトラック'; @@ -2310,9 +1542,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discographyDownloadSelected => '選択済みをダウンロード'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2368,9 +1597,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2433,11 +1659,6 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2528,21 +1749,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2552,11 +1758,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2582,72 +1783,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2674,18 +1809,6 @@ class AppLocalizationsJa extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2693,18 +1816,6 @@ class AppLocalizationsJa extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2765,9 +1876,6 @@ class AppLocalizationsJa extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2930,10 +2038,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3214,36 +2318,15 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'モードを選択'; + String downloadedAlbumDownloadedCount(int count) { + return '$count 個をダウンロード済み'; + } @override - String get setupModeSelectionDescription => - 'SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'ダウンローダー'; - - @override - String get setupModeDownloaderFeature1 => 'ロスレスFLAC品質でトラックをダウンロード'; - - @override - String get setupModeDownloaderFeature2 => 'オフライン再生用に音楽をデバイスに保存'; - - @override - String get setupModeDownloaderFeature3 => 'ローカル音楽ライブラリを管理'; - - @override - String get setupModeStreamingTitle => 'ストリーミング'; - - @override - String get setupModeStreamingFeature1 => 'ダウンロードせずにトラックを即座にストリーミング'; - - @override - String get setupModeStreamingFeature2 => 'Smart Queueが自動的に新しい音楽を見つけます'; - - @override - String get setupModeStreamingFeature3 => '再生コントロールで任意のトラックをオンデマンド再生'; - - @override - String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index ce04dc34..f94d1f3f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -11,19 +11,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색'; - - @override - String homeSearchHintExtension(String extensionName) { - return '$extensionName에서 검색'; - } - @override String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색'; @@ -50,17 +35,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeRecent => '최근 기록'; - @override - String get historyTitle => '기록'; - - @override - String historyDownloading(int count) { - return '다운로드 중... $count'; - } - - @override - String get historyDownloaded => '다운로드 목록'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '${count}tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -247,33 +130,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -376,18 +232,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -404,9 +248,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -480,9 +321,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -501,13 +339,6 @@ class AppLocalizationsKo extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -526,32 +357,6 @@ class AppLocalizationsKo extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -561,17 +366,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -580,27 +374,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -613,53 +386,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -681,9 +416,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -725,21 +457,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -756,13 +473,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -770,48 +480,12 @@ class AppLocalizationsKo extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -819,32 +493,19 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -854,21 +515,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -891,28 +540,9 @@ class AppLocalizationsKo extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1013,11 +643,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1059,44 +684,14 @@ class AppLocalizationsKo extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1106,24 +701,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1138,20 +721,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1178,40 +747,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1219,9 +757,6 @@ class AppLocalizationsKo extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1256,20 +791,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1300,12 +824,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1323,13 +841,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1350,18 +861,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1386,18 +885,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1407,48 +894,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1555,9 +1000,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1579,19 +1021,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1700,11 +1134,6 @@ class AppLocalizationsKo extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1726,18 +1155,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1759,15 +1176,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1918,38 +1326,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1964,24 +1340,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1997,14 +1355,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2016,78 +1366,18 @@ class AppLocalizationsKo extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2095,19 +1385,6 @@ class AppLocalizationsKo extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2128,30 +1405,6 @@ class AppLocalizationsKo extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2198,14 +1451,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2236,9 +1481,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2262,23 +1504,12 @@ class AppLocalizationsKo extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2323,9 +1554,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2381,9 +1609,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2446,11 +1671,6 @@ class AppLocalizationsKo extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2541,21 +1761,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2565,11 +1770,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2595,72 +1795,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2687,18 +1821,6 @@ class AppLocalizationsKo extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2706,18 +1828,6 @@ class AppLocalizationsKo extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2778,9 +1888,6 @@ class AppLocalizationsKo extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2943,10 +2050,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3227,36 +2330,15 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get setupModeSelectionTitle => '모드 선택'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => '다운로더'; - - @override - String get setupModeDownloaderFeature1 => '무손실 FLAC 품질로 트랙 다운로드'; - - @override - String get setupModeDownloaderFeature2 => '오프라인 감상을 위해 기기에 음악 저장'; - - @override - String get setupModeDownloaderFeature3 => '로컬 음악 라이브러리 관리'; - - @override - String get setupModeStreamingTitle => '스트리밍'; - - @override - String get setupModeStreamingFeature1 => '다운로드 없이 트랙을 즉시 스트리밍'; - - @override - String get setupModeStreamingFeature2 => 'Smart Queue가 자동으로 새로운 음악을 발견합니다'; - - @override - String get setupModeStreamingFeature3 => '재생 컨트롤로 원하는 트랙을 온디맨드 재생'; - - @override - String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 052c9204..be1be644 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -11,19 +11,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -248,33 +131,6 @@ class AppLocalizationsNl extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -377,18 +233,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -405,9 +249,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -481,9 +322,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -502,13 +340,6 @@ class AppLocalizationsNl extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -527,32 +358,6 @@ class AppLocalizationsNl extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -562,17 +367,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -581,27 +375,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -614,53 +387,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -682,9 +417,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -726,21 +458,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -757,13 +474,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -771,48 +481,12 @@ class AppLocalizationsNl extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -820,32 +494,19 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -855,21 +516,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -892,28 +541,9 @@ class AppLocalizationsNl extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1014,11 +644,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1060,44 +685,14 @@ class AppLocalizationsNl extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1107,24 +702,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1139,20 +722,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1179,40 +748,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1220,9 +758,6 @@ class AppLocalizationsNl extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1257,20 +792,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1301,12 +825,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1324,13 +842,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1351,18 +862,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1387,18 +886,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1408,48 +895,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1556,9 +1001,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1580,19 +1022,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1701,11 +1135,6 @@ class AppLocalizationsNl extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1727,18 +1156,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1760,15 +1177,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1919,38 +1327,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1965,24 +1341,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1998,14 +1356,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2017,78 +1367,18 @@ class AppLocalizationsNl extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2096,19 +1386,6 @@ class AppLocalizationsNl extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2129,30 +1406,6 @@ class AppLocalizationsNl extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2199,14 +1452,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2237,9 +1482,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2263,23 +1505,12 @@ class AppLocalizationsNl extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2324,9 +1555,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2382,9 +1610,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2447,11 +1672,6 @@ class AppLocalizationsNl extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2542,21 +1762,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2566,11 +1771,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2596,72 +1796,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2688,18 +1822,6 @@ class AppLocalizationsNl extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2707,18 +1829,6 @@ class AppLocalizationsNl extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2779,9 +1889,6 @@ class AppLocalizationsNl extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2944,10 +2051,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3228,43 +2331,15 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Kies je modus'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Downloader'; - - @override - String get setupModeDownloaderFeature1 => - 'Download nummers in lossless FLAC-kwaliteit'; - - @override - String get setupModeDownloaderFeature2 => - 'Sla muziek op je apparaat op om offline te luisteren'; - - @override - String get setupModeDownloaderFeature3 => - 'Beheer je lokale muziekbibliotheek'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Stream nummers direct zonder te downloaden'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue ontdekt automatisch nieuwe muziek voor je'; - - @override - String get setupModeStreamingFeature3 => - 'Speel elk nummer op aanvraag af met afspeelbediening'; - - @override - String get setupModeChangeableLater => - 'Je kunt op elk moment wisselen tussen modi in Instellingen.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 0262c76f..499adcbb 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -11,19 +11,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -248,33 +131,6 @@ class AppLocalizationsPt extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -377,18 +233,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -405,9 +249,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -481,9 +322,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -502,13 +340,6 @@ class AppLocalizationsPt extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -527,32 +358,6 @@ class AppLocalizationsPt extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -562,17 +367,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -581,27 +375,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -614,53 +387,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -682,9 +417,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -726,21 +458,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -757,13 +474,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -771,48 +481,12 @@ class AppLocalizationsPt extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -820,32 +494,19 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -855,21 +516,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -892,28 +541,9 @@ class AppLocalizationsPt extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1014,11 +644,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1060,44 +685,14 @@ class AppLocalizationsPt extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1107,24 +702,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1139,20 +722,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1179,40 +748,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1220,9 +758,6 @@ class AppLocalizationsPt extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1257,20 +792,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1301,12 +825,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1324,13 +842,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1351,18 +862,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1387,18 +886,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1408,48 +895,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1556,9 +1001,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1580,19 +1022,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1701,11 +1135,6 @@ class AppLocalizationsPt extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1727,18 +1156,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1760,15 +1177,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1919,38 +1327,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1965,24 +1341,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1998,14 +1356,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2017,78 +1367,18 @@ class AppLocalizationsPt extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2096,19 +1386,6 @@ class AppLocalizationsPt extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2129,30 +1406,6 @@ class AppLocalizationsPt extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2199,14 +1452,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2237,9 +1482,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2263,23 +1505,12 @@ class AppLocalizationsPt extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2324,9 +1555,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2382,9 +1610,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2447,11 +1672,6 @@ class AppLocalizationsPt extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2542,21 +1762,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2566,11 +1771,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2596,72 +1796,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2688,18 +1822,6 @@ class AppLocalizationsPt extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2707,18 +1829,6 @@ class AppLocalizationsPt extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2779,9 +1889,6 @@ class AppLocalizationsPt extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2944,10 +2051,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3228,45 +2331,17 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Escolha seu modo'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Downloader'; - - @override - String get setupModeDownloaderFeature1 => - 'Baixe faixas em qualidade FLAC lossless'; - - @override - String get setupModeDownloaderFeature2 => - 'Salve músicas no seu dispositivo para ouvir offline'; - - @override - String get setupModeDownloaderFeature3 => - 'Gerencie sua biblioteca de músicas local'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Transmita faixas instantaneamente sem baixar'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue descobre automaticamente novas músicas para você'; - - @override - String get setupModeStreamingFeature3 => - 'Reproduza qualquer faixa sob demanda com controles de reprodução'; - - @override - String get setupModeChangeableLater => - 'Você pode alternar entre os modos a qualquer momento nas Configurações.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -3276,19 +2351,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.'; - @override String get navHome => 'Início'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'Histórico'; - @override String get navSettings => 'Configurações'; @@ -3298,14 +2366,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get homeTitle => 'Início'; - @override - String get homeSearchHint => 'Pesquise ou cole a URL do Spotify...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Pesquisar com $extensionName...'; - } - @override String get homeSubtitle => 'Cole um link do Spotify ou procure por nome'; @@ -3316,17 +2376,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get homeRecent => 'Recentes'; - @override - String get historyTitle => 'Histórico'; - - @override - String historyDownloading(int count) { - return 'Baixando ($count)'; - } - - @override - String get historyDownloaded => 'Baixados'; - @override String get historyFilterAll => 'Tudo'; @@ -3336,48 +2385,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count faixas', - one: '1 faixa', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count álbuns', - one: '1 álbum', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Nenhum histórico de downloads'; - - @override - String get historyNoDownloadsSubtitle => 'As faixas baixadas aparecerão aqui'; - - @override - String get historyNoAlbums => 'Sem álbuns baixados'; - - @override - String get historyNoAlbumsSubtitle => - 'Baixe várias faixas de um álbum para vê-las aqui'; - - @override - String get historyNoSingles => 'Sem singles baixados'; - - @override - String get historyNoSinglesSubtitle => - 'Os downloads de faixa individuais aparecerão aqui'; - @override String get historySearchHint => 'Pesquisar histórico...'; @@ -3402,27 +2409,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Local dos Downloads'; - - @override - String get downloadLocationSubtitle => 'Escolha onde salvar os arquivos'; - - @override - String get downloadLocationDefault => 'Local padrão'; - - @override - String get downloadDefaultService => 'Serviço Padrão'; - - @override - String get downloadDefaultServiceSubtitle => 'Serviço usado para downloads'; - - @override - String get downloadDefaultQuality => 'Qualidade Predefinida'; - - @override - String get downloadAskQuality => 'Perguntar qualidade antes de baixar'; - @override String get downloadAskQualitySubtitle => 'Mostrar seletor de qualidade para cada download'; @@ -3433,31 +2419,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadFolderOrganization => 'Organização de Pastas'; - @override - String get downloadSeparateSingles => 'Separar Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Colocar singles numa pasta separada'; - - @override - String get qualityBest => 'Melhor Disponível'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Aparência'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistema'; @@ -3474,9 +2438,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get appearanceDynamicColorSubtitle => 'Usar cores do seu papel de parede'; - @override - String get appearanceAccentColor => 'Cor de Destaque'; - @override String get appearanceHistoryView => 'Visualização do Histórico'; @@ -3489,9 +2450,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get optionsTitle => 'Opções'; - @override - String get optionsSearchSource => 'Origem da Pesquisa'; - @override String get optionsPrimaryProvider => 'Provedor Primário'; @@ -3622,19 +2580,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get extensionsTitle => 'Extensões'; - @override - String get extensionsInstalled => 'Extensões Instaladas'; - - @override - String get extensionsNone => 'Nenhuma extensão instalada'; - - @override - String get extensionsNoneSubtitle => - 'Instalar extensões a partir da aba Loja'; - - @override - String get extensionsEnabled => 'Habilitado'; - @override String get extensionsDisabled => 'Desabilitado'; @@ -3651,9 +2596,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get extensionsUninstall => 'Desinstalar'; - @override - String get extensionsSetAsSearch => 'Definir como Provedor de Pesquisa'; - @override String get storeTitle => 'Loja de Extensões'; @@ -3729,9 +2671,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Apoiar'; - @override String get aboutApp => 'Aplicativo'; @@ -3750,13 +2689,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -3775,32 +2707,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get aboutAppDescription => 'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.'; - @override - String get albumTitle => 'Álbum'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count faixas', - one: '1 faixa', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Baixar Tudo'; - - @override - String get albumDownloadRemaining => 'Downloads Restantes'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artista'; - @override String get artistAlbums => 'Álbuns'; @@ -3810,17 +2716,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get artistCompilations => 'Compilações'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lançamentos', - one: '1 lançamento', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Populares'; @@ -3829,27 +2724,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return '$count ouvintes mensais'; } - @override - String get trackMetadataTitle => 'Informações da Faixa'; - - @override - String get trackMetadataArtist => 'Artista'; - - @override - String get trackMetadataAlbum => 'Álbum'; - - @override - String get trackMetadataDuration => 'Duração'; - - @override - String get trackMetadataQuality => 'Qualidade'; - - @override - String get trackMetadataPath => 'Caminho do Arquivo'; - - @override - String get trackMetadataDownloadedAt => 'Baixado'; - @override String get trackMetadataService => 'Serviço'; @@ -3862,53 +2736,15 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get trackMetadataDelete => 'Apagar'; - @override - String get trackMetadataRedownload => 'Baixar Novamente'; - - @override - String get trackMetadataOpenFolder => 'Abrir Pasta'; - - @override - String get setupTitle => 'Bem-vindo ao SpotiFLAC'; - - @override - String get setupSubtitle => 'Vamos começar'; - - @override - String get setupStoragePermission => 'Permissão de Armazenamento'; - - @override - String get setupStoragePermissionSubtitle => - 'Necessária para salvar arquivos baixados'; - - @override - String get setupStoragePermissionGranted => 'Permissão concedida'; - - @override - String get setupStoragePermissionDenied => 'Permissão negada'; - @override String get setupGrantPermission => 'Conceder Permissão'; - @override - String get setupDownloadLocation => 'Local do Download'; - - @override - String get setupChooseFolder => 'Selecionar Pasta'; - - @override - String get setupContinue => 'Continuar'; - @override String get setupSkip => 'Ignorar por enquanto'; @override String get setupStorageAccessRequired => 'Acesso ao Armazenamento Necessário'; - @override - String get setupStorageAccessMessage => - 'O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.'; - @override String get setupStorageAccessMessageAndroid11 => 'O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.'; @@ -3930,9 +2766,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'A permissão $permissionType é necessária para a melhor experiência. Você pode alterar isso mais tarde em Configurações.'; } - @override - String get setupSelectDownloadFolder => 'Escolher Pasta de Download'; - @override String get setupUseDefaultFolder => 'Usar Pasta Padrão?'; @@ -3975,21 +2808,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupDownloadInFlac => 'Baixar faixas do Spotify em FLAC'; - @override - String get setupStepStorage => 'Armazenamento'; - - @override - String get setupStepNotification => 'Notificação'; - - @override - String get setupStepFolder => 'Pasta'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permissão'; - @override String get setupStorageGranted => 'Permissão de Armazenamento Concedida!'; @@ -4006,13 +2824,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupNotificationEnable => 'Habilitar Notificações'; - @override - String get setupNotificationDescription => - 'Seja notificado quando os downloads completarem ou exigirem atenção.'; - - @override - String get setupFolderSelected => 'Pasta para Download Selecionada!'; - @override String get setupFolderChoose => 'Escolher Pasta de Download'; @@ -4020,49 +2831,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get setupFolderDescription => 'Selecione uma pasta onde as suas músicas baixadas serão salvas.'; - @override - String get setupChangeFolder => 'Alterar Pasta'; - @override String get setupSelectFolder => 'Seleccionar Pasta'; - @override - String get setupSpotifyApiOptional => 'API do Spotify (opcional)'; - - @override - String get setupSpotifyApiDescription => - 'Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.'; - - @override - String get setupUseSpotifyApi => 'Usar API do Spotify'; - - @override - String get setupEnterCredentialsBelow => 'Insira as suas credenciais abaixo'; - - @override - String get setupUsingDeezer => 'Usando o Deezer (nenhuma conta necessária)'; - - @override - String get setupEnterClientId => 'Insira o Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Insira o Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Habilitar Notificações'; - @override - String get setupProceedToNextStep => - 'Você já pode prosseguir para o próximo passo.'; - - @override - String get setupNotificationProgressDescription => - 'Você receberá notificações de progresso dos downloads.'; - @override String get setupNotificationBackgroundDescription => 'Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.'; @@ -4070,32 +2844,19 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupSkipForNow => 'Ignorar por enquanto'; - @override - String get setupBack => 'Voltar'; - @override String get setupNext => 'Próximo'; @override String get setupGetStarted => 'Começar'; - @override - String get setupSkipAndStart => 'Ignorar e Iniciar'; - @override String get setupAllowAccessToManageFiles => 'Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.'; - @override - String get setupGetCredentialsFromSpotify => - 'Obter credenciais do developer.spotify.com'; - @override String get dialogCancel => 'Cancelar'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Salvar'; @@ -4105,21 +2866,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get dialogRetry => 'Tentar novamente'; - @override - String get dialogClose => 'Fechar'; - - @override - String get dialogYes => 'Sim'; - - @override - String get dialogNo => 'Não'; - @override String get dialogClear => 'Limpar'; - @override - String get dialogConfirm => 'Confirmar'; - @override String get dialogDone => 'Concluído'; @@ -4142,28 +2891,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get dialogUnsavedChanges => 'Você tem alterações não salvas. Deseja descartá-las?'; - @override - String get dialogDownloadFailed => 'Download Falhou'; - - @override - String get dialogTrackLabel => 'Faixa:'; - - @override - String get dialogArtistLabel => 'Artista:'; - - @override - String get dialogErrorLabel => 'Erro:'; - @override String get dialogClearAll => 'Limpar Tudo'; - @override - String get dialogClearAllDownloads => - 'Você tem certeza que deseja limpar todos os downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remover do dispositivo?'; - @override String get dialogRemoveExtension => 'Remover Extensão'; @@ -4264,11 +2994,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get snackbarViewQueue => 'Ver Fila'; - @override - String snackbarFailedToLoad(String error) { - return 'Falha ao carregar: $error'; - } - @override String snackbarUrlCopied(String platform) { return 'URL do $platform copiado para a área de transferência'; @@ -4311,11 +3036,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get errorRateLimitedMessage => 'Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.'; - @override - String errorFailedToLoad(String item) { - return 'Falha ao carregar $item'; - } - @override String get errorNoTracksFound => 'Nenhuma faixa encontrada'; @@ -4324,27 +3044,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Não é possível carregar $item: faltando a fonte da extensão'; } - @override - String get statusQueued => 'Na Fila'; - - @override - String get statusDownloading => 'Baixando'; - - @override - String get statusFinalizing => 'Finalizando'; - - @override - String get statusCompleted => 'Concluído'; - - @override - String get statusFailed => 'Falhou'; - - @override - String get statusSkipped => 'Ignorado'; - - @override - String get statusPaused => 'Pausado'; - @override String get actionPause => 'Pausar'; @@ -4354,24 +3053,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get actionCancel => 'Cancelar'; - @override - String get actionStop => 'Parar'; - - @override - String get actionSelect => 'Selecionar'; - @override String get actionSelectAll => 'Selecionar Tudo'; @override String get actionDeselect => 'Desselecionar'; - @override - String get actionPaste => 'Colar'; - - @override - String get actionImportCsv => 'Importar CSV'; - @override String get actionRemoveCredentials => 'Remover Credenciais'; @@ -4386,20 +3073,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get selectionAllSelected => 'Todas as faixas selecionadas'; - @override - String get selectionTapToSelect => 'Toque nas faixas para selecionar'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'faixas', - one: 'faixa', - ); - return 'Apagar $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Selecione as faixas para apagar'; @@ -4426,43 +3099,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get tooltipPlay => 'Reproduzir'; - @override - String get tooltipCancel => 'Cancelar'; - - @override - String get tooltipStop => 'Parar'; - - @override - String get tooltipRetry => 'Tentar Novamente'; - - @override - String get tooltipRemove => 'Remover'; - - @override - String get tooltipClear => 'Limpar'; - - @override - String get tooltipPaste => 'Colar'; - @override String get filenameFormat => 'Formato do Nome do Arquivo'; - @override - String filenameFormatPreview(String preview) { - return 'Prévia: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Substituições permitidas:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Organização de Pastas'; - @override String get folderOrganizationNone => 'Nenhuma organização'; @@ -4498,20 +3137,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get updateAvailable => 'Atualização Disponível'; - @override - String updateNewVersion(String version) { - return 'A versão $version está disponível'; - } - - @override - String get updateDownload => 'Baixar'; - @override String get updateLater => 'Depois'; - @override - String get updateChangelog => 'Lista de alterações'; - @override String get updateStartingDownload => 'Iniciando download...'; @@ -4542,13 +3170,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get updateDontRemind => 'Não lembrar'; - @override - String get providerPriority => 'Prioridade de Provedor'; - - @override - String get providerPrioritySubtitle => - 'Arraste para reordenar os provedores de download'; - @override String get providerPriorityTitle => 'Prioridade de Provedor'; @@ -4566,13 +3187,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get providerExtension => 'Extensão'; - @override - String get metadataProviderPriority => 'Prioridade de Provedor de Metadados'; - - @override - String get metadataProviderPrioritySubtitle => - 'Ordem usada para obter metadados de faixa'; - @override String get metadataProviderPriorityTitle => 'Prioridade de Metadados'; @@ -4593,18 +3207,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get logTitle => 'Registros'; - @override - String get logCopy => 'Copiar Registros'; - - @override - String get logClear => 'Limpar Registros'; - - @override - String get logShare => 'Compartilhar Registros'; - - @override - String get logEmpty => 'Ainda não há registros'; - @override String get logCopied => 'Registros copiados para área de transferência'; @@ -4630,18 +3232,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get logClearLogsMessage => 'Tem certeza de que deseja limpar todos os registros?'; - @override - String get logIspBlocking => 'BLOQUEIO DE ISP DETECTADO'; - - @override - String get logRateLimited => 'TAXA LIMITADA (RATELIMITED)'; - - @override - String get logNetworkError => 'ERRO DE REDE'; - - @override - String get logTrackNotFound => 'FAIXA NÃO ENCONTRADA'; - @override String get logFilterBySeverity => 'Filtrar registros por gravidade'; @@ -4652,48 +3242,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get logNoLogsYetSubtitle => 'Os registros aparecerão aqui enquanto você usa o aplicativo'; - @override - String get logIssueSummary => 'Resumo do Problemas'; - - @override - String get logIspBlockingDescription => - 'O seu provedor pode estar bloqueando o acesso aos serviços de download'; - - @override - String get logIspBlockingSuggestion => - 'Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Muitas solicitações ao serviço'; - - @override - String get logRateLimitedSuggestion => - 'Aguarde alguns minutos antes de tentar novamente'; - - @override - String get logNetworkErrorDescription => 'Problemas de conexão detectados'; - - @override - String get logNetworkErrorSuggestion => 'Verifique sua conexão de internet'; - - @override - String get logTrackNotFoundDescription => - 'Algumas faixas não foram encontradas nos serviços de download'; - - @override - String get logTrackNotFoundSuggestion => - 'A faixa pode não estar disponível em qualidade sem perdas'; - - @override - String logTotalErrors(int count) { - return 'Total de erros: $count'; - } - - @override - String logAffected(String domains) { - return 'Afetado(s): $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entradas ($count filtradas)'; @@ -4801,9 +3349,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get appearanceLanguage => 'Idioma do aplicativo'; - @override - String get appearanceLanguageSubtitle => 'Escolha o seu idioma preferido'; - @override String get settingsAppearanceSubtitle => 'Tema, cores, exibição'; @@ -4827,9 +3372,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get pressBackAgainToExit => 'Pressione voltar novamente para sair'; - @override - String get tracksHeader => 'Faixas'; - @override String downloadAllCount(int count) { return 'Baixar Todos ($count)'; @@ -4944,11 +3486,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get trackDeleteConfirmMessage => 'Isto irá excluir o arquivo baixado permanentemente e removê-lo do seu histórico.'; - @override - String trackCannotOpen(String message) { - return 'Não foi possível abrir: $message'; - } - @override String get dateToday => 'Hoje'; @@ -4970,18 +3507,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return '$count meses atrás'; } - @override - String get concurrentSequential => 'Sequencial'; - - @override - String get concurrentParallel2 => '2 Paralelos'; - - @override - String get concurrentParallel3 => '3 Paralelos'; - - @override - String get tapToSeeError => 'Toque para ver os detalhes do erro'; - @override String get storeFilterAll => 'Tudo'; @@ -5003,15 +3528,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get storeClearFilters => 'Limpar filtros'; - @override - String get storeNoResults => 'Nenhuma extensão encontrada'; - - @override - String get extensionProviderPriority => 'Prioridade de Provedor'; - - @override - String get extensionInstallButton => 'Instalar Extensão'; - @override String get extensionDefaultProvider => 'Padrão (Deezer/Spotify)'; @@ -5165,38 +3681,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get qualityHiResFlacMaxSubtitle => '24-bit / até 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'A qualidade real depende da faixa que estiver disponível no serviço'; @@ -5220,14 +3704,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -5239,80 +3715,18 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Formato para Salvar'; - - @override - String get downloadSelectService => 'Selecionar Serviço'; - @override String get downloadSelectQuality => 'Selecionar Qualidade'; @override String get downloadFrom => 'Baixar De'; - @override - String get downloadDefaultQualityLabel => 'Qualidade Padrão'; - - @override - String get downloadBestAvailable => 'Melhor Disponível'; - - @override - String get folderNone => 'Nenhuma'; - - @override - String get folderNoneSubtitle => - 'Salvar todos os arquivos diretamente na pasta de download'; - - @override - String get folderArtist => 'Artista'; - - @override - String get folderArtistSubtitle => 'Nome do Artista/arquivo'; - - @override - String get folderAlbum => 'Álbum'; - - @override - String get folderAlbumSubtitle => 'Nome do Álbum/arquivo'; - - @override - String get folderArtistAlbum => 'Artista/Álbum'; - - @override - String get folderArtistAlbumSubtitle => - 'Nome do Artista/Nome do Álbum/arquivo'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'Escuro AMOLED'; @override String get appearanceAmoledDarkSubtitle => 'Fundo preto puro'; - @override - String get appearanceChooseAccentColor => 'Escolha a Cor de Destaque'; - - @override - String get appearanceChooseTheme => 'Modo do Tema'; - - @override - String get queueTitle => 'Fila de Download'; - @override String get queueClearAll => 'Limpar Tudo'; @@ -5320,19 +3734,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get queueClearAllMessage => 'Você tem certeza que deseja limpar todos os downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -5353,30 +3754,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'Nenhum download na fila'; - - @override - String get queueEmptySubtitle => 'Adicione faixas a partir da tela inicial'; - - @override - String get queueClearCompleted => 'Limpar concluídos'; - - @override - String get queueDownloadFailed => 'Download Falhou'; - - @override - String get queueTrackLabel => 'Faixa:'; - - @override - String get queueArtistLabel => 'Artista:'; - - @override - String get queueErrorLabel => 'Erro:'; - - @override - String get queueUnknownError => 'Erro desconhecido'; - @override String get albumFolderArtistAlbum => 'Artista / Álbum'; @@ -5424,14 +3801,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Excluir $count $_temp0 deste álbum?\n\nIsso também excluirá os arquivos do armazenamento.'; } - @override - String get downloadedAlbumTracksHeader => 'Faixas'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count baixado(s)'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selecionado(s)'; @@ -5462,9 +3831,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Disco $discNumber'; } - @override - String get utilityFunctions => 'Funções Utilitárias'; - @override String get recentTypeArtist => 'Artista'; @@ -5488,11 +3854,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Erro: $message'; - } - @override String get discographyDownload => 'Baixar Discografia'; @@ -5598,9 +3959,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -5663,11 +4021,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -5747,21 +4100,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -5771,11 +4109,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -5801,72 +4134,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -5893,18 +4160,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -5912,18 +4167,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -5984,9 +4227,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -6149,10 +4389,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -6229,43 +4465,15 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get trackConvertFailed => 'Conversion failed'; @override - String get setupModeSelectionTitle => 'Escolha o seu modo'; + String downloadedAlbumDownloadedCount(int count) { + return '$count baixado(s)'; + } @override - String get setupModeSelectionDescription => - 'Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Transferência'; - - @override - String get setupModeDownloaderFeature1 => - 'Transfira faixas em qualidade FLAC sem perdas'; - - @override - String get setupModeDownloaderFeature2 => - 'Guarde música no seu dispositivo para ouvir offline'; - - @override - String get setupModeDownloaderFeature3 => - 'Faça a gestão da sua biblioteca de música local'; - - @override - String get setupModeStreamingTitle => 'Streaming'; - - @override - String get setupModeStreamingFeature1 => - 'Transmita faixas instantaneamente sem transferir'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue descobre automaticamente novas músicas para si'; - - @override - String get setupModeStreamingFeature3 => - 'Reproduza qualquer faixa a pedido com controlos de reprodução'; - - @override - String get setupModeChangeableLater => - 'Pode alternar entre modos a qualquer momento nas Definições.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e6b4a1f8..85af897d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -11,19 +11,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; - @override String get navHome => 'Главная'; @override String get navLibrary => 'Библиотека'; - @override - String get navHistory => 'История'; - @override String get navSettings => 'Настройки'; @@ -33,14 +26,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get homeTitle => 'Главная'; - @override - String get homeSearchHint => 'Вставьте URL Spotify или выполните поиск...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Искать с помощью $extensionName...'; - } - @override String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию'; @@ -51,17 +36,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get homeRecent => 'Недавние'; - @override - String get historyTitle => 'История'; - - @override - String historyDownloading(int count) { - return 'Скачивание ($count)'; - } - - @override - String get historyDownloaded => 'Скачано'; - @override String get historyFilterAll => 'Все'; @@ -71,52 +45,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get historyFilterSingles => 'Синглы'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count альбомов', - many: '$count альбомов', - few: '$count альбома', - one: '$count альбом', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Нет истории скачиваний'; - - @override - String get historyNoDownloadsSubtitle => 'Скачанные треки появятся здесь'; - - @override - String get historyNoAlbums => 'Нет скачанных альбомов'; - - @override - String get historyNoAlbumsSubtitle => - 'Скачайте несколько треков из альбома, чтобы увидеть их здесь'; - - @override - String get historyNoSingles => 'Нет скачанных синглов'; - - @override - String get historyNoSinglesSubtitle => - 'Здесь будут отображаться загрузки синглов'; - @override String get historySearchHint => 'Поиск в истории...'; @@ -141,28 +69,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadTitle => 'Скачивание'; - @override - String get downloadLocation => 'Папка для скачивания'; - - @override - String get downloadLocationSubtitle => 'Выберите, куда сохранить файлы'; - - @override - String get downloadLocationDefault => 'Расположение по умолчанию'; - - @override - String get downloadDefaultService => 'Сервис по умолчанию'; - - @override - String get downloadDefaultServiceSubtitle => - 'Сервис, используемый для скачивания'; - - @override - String get downloadDefaultQuality => 'Качество по умолчанию'; - - @override - String get downloadAskQuality => 'Спрашивать качество перед скачиванием'; - @override String get downloadAskQualitySubtitle => 'Показывать выбор качества для каждого скачивания'; @@ -173,31 +79,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadFolderOrganization => 'Организация папок'; - @override - String get downloadSeparateSingles => 'Разделять синглы'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Помещать синглы в отдельную папку'; - - @override - String get qualityBest => 'Лучшее из доступных'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 кбит/с'; - - @override - String get quality128 => '128 кбит/с'; - @override String get appearanceTitle => 'Внешний вид'; - @override - String get appearanceTheme => 'Тема'; - @override String get appearanceThemeSystem => 'Системная'; @@ -214,9 +98,6 @@ class AppLocalizationsRu extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Использовать цвета из ваших обоев'; - @override - String get appearanceAccentColor => 'Акцентный цвет'; - @override String get appearanceHistoryView => 'Отображение истории'; @@ -229,9 +110,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get optionsTitle => 'Опции'; - @override - String get optionsSearchSource => 'Поиск источника'; - @override String get optionsPrimaryProvider => 'Основной провайдер'; @@ -255,33 +133,6 @@ class AppLocalizationsRu extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Попробовать другие сервисы при сбое загрузки'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Использовать провайдера расширений'; @@ -388,19 +239,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get extensionsTitle => 'Расширения'; - @override - String get extensionsInstalled => 'Установленные расширения'; - - @override - String get extensionsNone => 'Нет установленных расширений'; - - @override - String get extensionsNoneSubtitle => - 'Установка расширений из вкладки Магазин'; - - @override - String get extensionsEnabled => 'Включено'; - @override String get extensionsDisabled => 'Выключено'; @@ -417,9 +255,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get extensionsUninstall => 'Удалить'; - @override - String get extensionsSetAsSearch => 'Установить в качестве поисковой системы'; - @override String get storeTitle => 'Магазин расширений'; @@ -494,9 +329,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutSocial => 'Соцсети'; - @override - String get aboutSupport => 'Поддержка'; - @override String get aboutApp => 'Приложение'; @@ -515,13 +347,6 @@ class AppLocalizationsRu extends AppLocalizations { String get aboutSjdonadoDesc => 'Создатель I Don\'t Have Spotify (IDHS). Резервный резолвер ссылки'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Удивительный API для загрузок Amazon Music. Спасибо за то, что сделали это бесплатно!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -540,34 +365,6 @@ class AppLocalizationsRu extends AppLocalizations { String get aboutAppDescription => 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; - @override - String get albumTitle => 'Альбом'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Скачать всё'; - - @override - String get albumDownloadRemaining => 'Скачать оставшиеся'; - - @override - String get playlistTitle => 'Плейлист'; - - @override - String get artistTitle => 'Исполнитель'; - @override String get artistAlbums => 'Альбомы'; @@ -577,19 +374,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get artistCompilations => 'Сборники'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count релизов', - many: '$count релизов', - few: '$count релиза', - one: '$count релиз', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Популярное'; @@ -598,27 +382,6 @@ class AppLocalizationsRu extends AppLocalizations { return '$count слушателей в месяц'; } - @override - String get trackMetadataTitle => 'Информация о треке'; - - @override - String get trackMetadataArtist => 'Исполнитель'; - - @override - String get trackMetadataAlbum => 'Альбом'; - - @override - String get trackMetadataDuration => 'Продолжительность'; - - @override - String get trackMetadataQuality => 'Качество'; - - @override - String get trackMetadataPath => 'Путь к файлу'; - - @override - String get trackMetadataDownloadedAt => 'Скачано'; - @override String get trackMetadataService => 'Сервис'; @@ -631,53 +394,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackMetadataDelete => 'Удалить'; - @override - String get trackMetadataRedownload => 'Скачать снова'; - - @override - String get trackMetadataOpenFolder => 'Открыть папку'; - - @override - String get setupTitle => 'Добро пожаловать в SpotiFLAC'; - - @override - String get setupSubtitle => 'Давайте начнем'; - - @override - String get setupStoragePermission => 'Доступ к хранилищу'; - - @override - String get setupStoragePermissionSubtitle => - 'Необходимо для сохранения загруженных файлов'; - - @override - String get setupStoragePermissionGranted => 'Разрешение предоставлено'; - - @override - String get setupStoragePermissionDenied => 'Разрешение не предоставлено'; - @override String get setupGrantPermission => 'Предоставить разрешение'; - @override - String get setupDownloadLocation => 'Папка для скачивания'; - - @override - String get setupChooseFolder => 'Выбрать папку'; - - @override - String get setupContinue => 'Продолжить'; - @override String get setupSkip => 'Пропустить'; @override String get setupStorageAccessRequired => 'Требуется доступ к хранилищу'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC требуется разрешение \"Доступ ко всем файлам\" для сохранения музыкальных файлов в выбранную папку.'; - @override String get setupStorageAccessMessageAndroid11 => 'Для Android 11+ требуется разрешение \"Доступ ко всем файлам\" для сохранения файлов в выбранную вами папку загрузки.'; @@ -699,9 +424,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Для оптимальной работы требуется разрешение $permissionType. Вы можете изменить это позже в настройках.'; } - @override - String get setupSelectDownloadFolder => 'Выбрать папку для скачивания'; - @override String get setupUseDefaultFolder => 'Использовать папку по умолчанию?'; @@ -744,21 +466,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC'; - @override - String get setupStepStorage => 'Хранилище'; - - @override - String get setupStepNotification => 'Уведомления'; - - @override - String get setupStepFolder => 'Папка'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Разрешение'; - @override String get setupStorageGranted => 'Доступ к хранилищу предоставлен!'; @@ -776,13 +483,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupNotificationEnable => 'Включить уведомления'; - @override - String get setupNotificationDescription => - 'Получайте уведомления о завершении загрузки или о необходимости привлечения внимания.'; - - @override - String get setupFolderSelected => 'Папка для загрузки выбрана!'; - @override String get setupFolderChoose => 'Выбрать папку для скачивания'; @@ -790,49 +490,12 @@ class AppLocalizationsRu extends AppLocalizations { String get setupFolderDescription => 'Выберите папку, в которой будет сохраняться скачанная музыка.'; - @override - String get setupChangeFolder => 'Сменить папку'; - @override String get setupSelectFolder => 'Выбрать папку'; - @override - String get setupSpotifyApiOptional => 'Spotify API (необязательно)'; - - @override - String get setupSpotifyApiDescription => - 'Добавьте свои учётные данные Spotify для улучшения результатов поиска и доступа к эксклюзивному контенту Spotify.'; - - @override - String get setupUseSpotifyApi => 'Использовать Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Введите ваши учётные данные ниже'; - - @override - String get setupUsingDeezer => 'Использование Deezer (аккаунт не требуется)'; - - @override - String get setupEnterClientId => 'Введите Client ID Spotify'; - - @override - String get setupEnterClientSecret => 'Введите Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Получите бесплатный API учётной записи на панели разработчика Spotify.'; - @override String get setupEnableNotifications => 'Включить уведомления'; - @override - String get setupProceedToNextStep => - 'Теперь вы можете перейти к следующему шагу.'; - - @override - String get setupNotificationProgressDescription => - 'Вы будете получать уведомления о ходе загрузки.'; - @override String get setupNotificationBackgroundDescription => 'Получайте уведомления о ходе и завершении загрузки. Это поможет вам отслеживать загрузки, когда приложение находится в фоновом режиме.'; @@ -840,32 +503,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupSkipForNow => 'Пропустить'; - @override - String get setupBack => 'Назад'; - @override String get setupNext => 'Далее'; @override String get setupGetStarted => 'Приступить к работе'; - @override - String get setupSkipAndStart => 'Пропустить и начать'; - @override String get setupAllowAccessToManageFiles => 'Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.'; - @override - String get setupGetCredentialsFromSpotify => - 'Получить учётные данные с developer.spotify.com'; - @override String get dialogCancel => 'Отмена'; - @override - String get dialogOk => 'ОК'; - @override String get dialogSave => 'Сохранить'; @@ -875,21 +525,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get dialogRetry => 'Повторить'; - @override - String get dialogClose => 'Закрыть'; - - @override - String get dialogYes => 'Да'; - - @override - String get dialogNo => 'Нет'; - @override String get dialogClear => 'Очистить'; - @override - String get dialogConfirm => 'Подтвердить'; - @override String get dialogDone => 'Готово'; @@ -912,28 +550,9 @@ class AppLocalizationsRu extends AppLocalizations { String get dialogUnsavedChanges => 'Есть несохраненные изменения. Отменить их?'; - @override - String get dialogDownloadFailed => 'Ошибка скачивания'; - - @override - String get dialogTrackLabel => 'Трек:'; - - @override - String get dialogArtistLabel => 'Исполнитель:'; - - @override - String get dialogErrorLabel => 'Ошибка:'; - @override String get dialogClearAll => 'Очистить всё'; - @override - String get dialogClearAllDownloads => - 'Вы уверены, что хотите очистить все загрузки?'; - - @override - String get dialogRemoveFromDevice => 'Удалить с устройства?'; - @override String get dialogRemoveExtension => 'Удалить расширение'; @@ -1038,11 +657,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get snackbarViewQueue => 'Просмотр очереди'; - @override - String snackbarFailedToLoad(String error) { - return 'Ошибка загрузки: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform ссылка скопирована в буфер обмена'; @@ -1085,44 +699,14 @@ class AppLocalizationsRu extends AppLocalizations { String get errorRateLimitedMessage => 'Слишком много запросов. Пожалуйста, подождите минуту перед повторным поиском.'; - @override - String errorFailedToLoad(String item) { - return 'Ошибка загрузки $item'; - } - @override String get errorNoTracksFound => 'Треки не найдены'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Невозможно загрузить $item: отсутствует источник расширения'; } - @override - String get statusQueued => 'В очереди'; - - @override - String get statusDownloading => 'Скачивание'; - - @override - String get statusFinalizing => 'Завершение'; - - @override - String get statusCompleted => 'Завершено'; - - @override - String get statusFailed => 'Неудачно'; - - @override - String get statusSkipped => 'Пропущено'; - - @override - String get statusPaused => 'Приостановлено'; - @override String get actionPause => 'Пауза'; @@ -1132,24 +716,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get actionCancel => 'Отмена'; - @override - String get actionStop => 'Стоп'; - - @override - String get actionSelect => 'Выбрать'; - @override String get actionSelectAll => 'Выбрать все'; @override String get actionDeselect => 'Снять выделение'; - @override - String get actionPaste => 'Вставить'; - - @override - String get actionImportCsv => 'Импорт CSV'; - @override String get actionRemoveCredentials => 'Удалить учётные данные'; @@ -1164,22 +736,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get selectionAllSelected => 'Все треки выбраны'; - @override - String get selectionTapToSelect => 'Нажмите на треки для выбора'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'треков', - many: 'треков', - few: 'трека', - one: 'трек', - ); - return 'Удалить $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Выберите треки для удаления'; @@ -1206,40 +762,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tooltipPlay => 'Воспроизвести'; - @override - String get tooltipCancel => 'Отмена'; - - @override - String get tooltipStop => 'Стоп'; - - @override - String get tooltipRetry => 'Повторить'; - - @override - String get tooltipRemove => 'Убрать'; - - @override - String get tooltipClear => 'Очистить'; - - @override - String get tooltipPaste => 'Вставить'; - @override String get filenameFormat => 'Формат имени файла'; - @override - String filenameFormatPreview(String preview) { - return 'Предпросмотр: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Доступные заполнители:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1247,9 +772,6 @@ class AppLocalizationsRu extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Организация папок'; - @override String get folderOrganizationNone => 'Без организации'; @@ -1284,20 +806,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get updateAvailable => 'Доступно обновление'; - @override - String updateNewVersion(String version) { - return 'Версия $version доступна'; - } - - @override - String get updateDownload => 'Скачать'; - @override String get updateLater => 'Позже'; - @override - String get updateChangelog => 'Список изменений'; - @override String get updateStartingDownload => 'Загрузка началась...'; @@ -1328,12 +839,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get updateDontRemind => 'Не напоминать'; - @override - String get providerPriority => 'Приоритет провайдера'; - - @override - String get providerPrioritySubtitle => 'Перетащите для изменения порядка'; - @override String get providerPriorityTitle => 'Приоритет провайдера'; @@ -1351,13 +856,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get providerExtension => 'Расширение'; - @override - String get metadataProviderPriority => 'Приоритет провайдера метаданных'; - - @override - String get metadataProviderPrioritySubtitle => - 'Порядок, используемый при получении метаданных'; - @override String get metadataProviderPriorityTitle => 'Приоритет метаданных'; @@ -1378,18 +876,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get logTitle => 'Логи'; - @override - String get logCopy => 'Скопировать логи'; - - @override - String get logClear => 'Очистить логи'; - - @override - String get logShare => 'Поделиться логами'; - - @override - String get logEmpty => 'Логов нет'; - @override String get logCopied => 'Логи скопированы в буфер обмена'; @@ -1414,18 +900,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get logClearLogsMessage => 'Вы уверены, что хотите очистить все логи?'; - @override - String get logIspBlocking => 'ОБНАРУЖЕНА БЛОКИРОВКА ИНТЕРНЕТ ПРОВАЙДЕРОМ'; - - @override - String get logRateLimited => 'ОГРАНИЧЕННАЯ СКОРОСТЬ'; - - @override - String get logNetworkError => 'ОШИБКА СЕТИ'; - - @override - String get logTrackNotFound => 'ТРЕК НЕ НАЙДЕН'; - @override String get logFilterBySeverity => 'Фильтровать логи по серьезности'; @@ -1436,48 +910,6 @@ class AppLocalizationsRu extends AppLocalizations { String get logNoLogsYetSubtitle => 'Логи появятся здесь по мере использования приложения'; - @override - String get logIssueSummary => 'Краткое описание проблемы'; - - @override - String get logIspBlockingDescription => - 'Ваш провайдер может блокировать доступ к сервисам скачивания'; - - @override - String get logIspBlockingSuggestion => - 'Попробуйте использовать VPN или измените DNS на 1.1.1.1 или 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Слишком много запросов к сервису'; - - @override - String get logRateLimitedSuggestion => - 'Подождите несколько минут, прежде чем повторить попытку'; - - @override - String get logNetworkErrorDescription => 'Обнаружены проблемы с подключением'; - - @override - String get logNetworkErrorSuggestion => 'Проверьте подключение к Интернету'; - - @override - String get logTrackNotFoundDescription => - 'Некоторые треки не найдены в сервисах загрузки'; - - @override - String get logTrackNotFoundSuggestion => - 'Трек может быть недоступен в lossless формате'; - - @override - String logTotalErrors(int count) { - return 'Всего ошибок: $count'; - } - - @override - String logAffected(String domains) { - return 'Затронуто: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Записи ($count фильтровано)'; @@ -1584,9 +1016,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceLanguage => 'Язык приложения'; - @override - String get appearanceLanguageSubtitle => 'Выберите предпочитаемый язык'; - @override String get settingsAppearanceSubtitle => 'Тема, цвета, дисплей'; @@ -1610,19 +1039,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pressBackAgainToExit => 'Нажмите «Назад» ещё раз, чтобы выйти'; - @override - String get tracksHeader => 'Треки'; - @override String downloadAllCount(int count) { return 'Скачать все ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1735,11 +1156,6 @@ class AppLocalizationsRu extends AppLocalizations { String get trackDeleteConfirmMessage => 'Это приведет к окончательному удалению загруженного файла и его удалению из истории.'; - @override - String trackCannotOpen(String message) { - return 'Невозможно открыть: $message'; - } - @override String get dateToday => 'Сегодня'; @@ -1761,18 +1177,6 @@ class AppLocalizationsRu extends AppLocalizations { return '$count месяцев назад'; } - @override - String get concurrentSequential => 'Последовательно'; - - @override - String get concurrentParallel2 => '2 параллельно'; - - @override - String get concurrentParallel3 => '3 параллельно'; - - @override - String get tapToSeeError => 'Нажмите, чтобы увидеть подробности ошибки'; - @override String get storeFilterAll => 'Все'; @@ -1794,15 +1198,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storeClearFilters => 'Очистить фильтры'; - @override - String get storeNoResults => 'Расширения не найдены'; - - @override - String get extensionProviderPriority => 'Приоритет провайдера'; - - @override - String get extensionInstallButton => 'Установить расширение'; - @override String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)'; @@ -1957,40 +1352,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => - 'Opus 320 кбит/с (конвертировать из FLAC)'; - - @override - String get qualityLossyOpusSubtitle => - 'Opus 128 кбит/с (конвертировать из FLAC)'; - - @override - String get enableLossyOption => 'Включить опцию Lossy'; - - @override - String get enableLossyOptionSubtitleOn => 'Доступно качество с потерями'; - - @override - String get enableLossyOptionSubtitleOff => - 'Скачивать FLAC и конвертировать в MP3 320 кбит/с'; - - @override - String get lossyFormat => 'Формат с потерями'; - - @override - String get lossyFormatDescription => 'Выберите Lossy формат для конвертации'; - - @override - String get lossyFormatMp3Subtitle => '320Кбит/с, лучшая совместимость'; - - @override - String get lossyFormatOpusSubtitle => - '128кбит/с, лучшее качество при меньших размерах'; - @override String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; @@ -2005,24 +1366,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @@ -2039,14 +1382,6 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadUseAlbumArtistForFolders => 'Использовать исполнителя альбома для папок'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Папки исполнителя используют только трек исполнителя'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2058,79 +1393,18 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Формат сохранения'; - - @override - String get downloadSelectService => 'Выбор сервиса'; - @override String get downloadSelectQuality => 'Выбор качества'; @override String get downloadFrom => 'Скачивать из'; - @override - String get downloadDefaultQualityLabel => 'Качество по умолчанию'; - - @override - String get downloadBestAvailable => 'Лучшее из доступных'; - - @override - String get folderNone => 'Отсутствует'; - - @override - String get folderNoneSubtitle => - 'Сохранить все файлы непосредственно в папку загрузки'; - - @override - String get folderArtist => 'Исполнитель'; - - @override - String get folderArtistSubtitle => 'Исполнитель/имя файла'; - - @override - String get folderAlbum => 'Альбом'; - - @override - String get folderAlbumSubtitle => 'Альбом/имя файла'; - - @override - String get folderArtistAlbum => 'Исполнитель/Альбом'; - - @override - String get folderArtistAlbumSubtitle => 'Исполнитель/ Альбом/имя файла'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED'; @override String get appearanceAmoledDarkSubtitle => 'Глубокий чёрный фон'; - @override - String get appearanceChooseAccentColor => 'Выберите акцентный цвет'; - - @override - String get appearanceChooseTheme => 'Режим темы'; - - @override - String get queueTitle => 'Очередь скачиваний'; - @override String get queueClearAll => 'Очистить всё'; @@ -2138,19 +1412,6 @@ class AppLocalizationsRu extends AppLocalizations { String get queueClearAllMessage => 'Вы уверены, что хотите очистить все загрузки?'; - @override - String get queueExportFailed => 'Экспорт'; - - @override - String get queueExportFailedSuccess => - 'Сбой при экспорте загрузок в файл TXT'; - - @override - String get queueExportFailedClear => 'Не удалось очистить'; - - @override - String get queueExportFailedError => 'Не удалось экспортировать загрузки'; - @override String get settingsAutoExportFailed => 'Автоэкспорт неудачных загрузок'; @@ -2171,30 +1432,6 @@ class AppLocalizationsRu extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Выберите, какую сеть использовать для скачивания. Когда установлено значение только WiFi — скачивания через мобильную сеть будут приостановлены.'; - @override - String get queueEmpty => 'Нет загрузок в очереди'; - - @override - String get queueEmptySubtitle => 'Добавить треки с главного экрана'; - - @override - String get queueClearCompleted => 'Очистка завершена'; - - @override - String get queueDownloadFailed => 'Ошибка скачивания'; - - @override - String get queueTrackLabel => 'Трек:'; - - @override - String get queueArtistLabel => 'Исполнитель:'; - - @override - String get queueErrorLabel => 'Ошибка:'; - - @override - String get queueUnknownError => 'Неизвестная ошибка'; - @override String get albumFolderArtistAlbum => 'Исполнитель / Альбом'; @@ -2245,14 +1482,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; } - @override - String get downloadedAlbumTracksHeader => 'Треки'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count скачано'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count выбрано'; @@ -2285,9 +1514,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Диск $discNumber'; } - @override - String get utilityFunctions => 'Функции утилиты'; - @override String get recentTypeArtist => 'Исполнитель'; @@ -2311,23 +1537,12 @@ class AppLocalizationsRu extends AppLocalizations { return 'Плейлист: $name'; } - @override - String errorGeneric(String message) { - return 'Ошибка: $message'; - } - @override String get discographyDownload => 'Скачать дискографию'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Скачать всё'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count треков из $albumCount релизов'; @@ -2372,9 +1587,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discographyDownloadSelected => 'Скачать выбранное'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Добавлено $count треков в очередь'; @@ -2433,9 +1645,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryTitle => 'Локальная библиотека'; - @override - String get libraryStatus => 'Статус Библиотеки'; - @override String get libraryScanSettings => 'Настройки сканирования'; @@ -2498,19 +1707,6 @@ class AppLocalizationsRu extends AppLocalizations { String get libraryAboutDescription => 'Сканирует существующую коллекцию музыки для обнаружения дубликатов при загрузке. Поддерживает форматы FLAC, M4A, MP3, Opus и OGG. Метаданные читаются из тегов файлов, если доступны.'; - @override - String libraryTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'треков', - many: 'треков', - few: 'трека', - one: 'трек', - ); - return '$count $_temp0'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2609,21 +1805,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFilterFormat => 'Формат'; - @override - String get libraryFilterDate => 'Дата добавления'; - - @override - String get libraryFilterDateToday => 'Сегодня'; - - @override - String get libraryFilterDateWeek => 'На этой неделе'; - - @override - String get libraryFilterDateMonth => 'В этом месяце'; - - @override - String get libraryFilterDateYear => 'В этом году'; - @override String get libraryFilterSort => 'Сортировка'; @@ -2633,11 +1814,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFilterSortOldest => 'Старые'; - @override - String libraryFilterActive(int count) { - return '$count фильтр(-ов) активно'; - } - @override String get timeJustNow => 'Только что'; @@ -2667,96 +1843,6 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0 назад'; } - @override - String get storageSwitchTitle => 'Сменить режим хранения'; - - @override - String get storageSwitchToSafTitle => 'Переключиться на SAF хранилище?'; - - @override - String get storageSwitchToAppTitle => 'Переключиться хранилище приложения?'; - - @override - String get storageSwitchToSafMessage => - 'Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.'; - - @override - String get storageSwitchToAppMessage => - 'Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.'; - - @override - String get storageSwitchExistingDownloads => 'Существующие загрузки'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0 в $mode хранилище'; - } - - @override - String get storageSwitchNewDownloads => 'Новые загрузки'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Будет сохранено в: $location'; - } - - @override - String get storageSwitchContinue => 'Продолжить'; - - @override - String get storageSwitchSelectFolder => 'Выберите папку SAF'; - - @override - String get storageAppStorage => 'Хранилище приложения'; - - @override - String get storageSafStorage => 'Хранилище SAF'; - - @override - String storageModeBadge(String mode) { - return 'Хранилище: $mode'; - } - - @override - String get storageStatsTitle => 'Статистика хранилища'; - - @override - String storageStatsAppCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0 в хранилище приложения'; - } - - @override - String storageStatsSafCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0 в вашей папке в SAF'; - } - - @override - String get storageModeInfo => 'Ваши файлы хранятся в нескольких местах'; - @override String get tutorialWelcomeTitle => 'Добро пожаловать в SpotiFLAC!'; @@ -2783,18 +1869,6 @@ class AppLocalizationsRu extends AppLocalizations { String get tutorialSearchDesc => 'Есть два простых способа найти музыку, которую вы хотите скачать.'; - @override - String get tutorialSearchTip1 => - 'Вставьте ссылку Spotify или Deezer прямо в поле поиска'; - - @override - String get tutorialSearchTip2 => - 'Или введите название песни, исполнителя или альбом для поиска'; - - @override - String get tutorialSearchTip3 => - 'Поддержка треков, альбомов, плейлистов и страниц исполнителей'; - @override String get tutorialDownloadTitle => 'Скачивание музыки'; @@ -2802,18 +1876,6 @@ class AppLocalizationsRu extends AppLocalizations { String get tutorialDownloadDesc => 'Скачивание музыки просто и быстро. Вот как это работает.'; - @override - String get tutorialDownloadTip1 => - 'Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание'; - - @override - String get tutorialDownloadTip2 => - 'Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Скачать все альбомы или плейлисты одним нажатием'; - @override String get tutorialLibraryTitle => 'Ваша библиотека'; @@ -2874,9 +1936,6 @@ class AppLocalizationsRu extends AppLocalizations { String get tutorialReadyMessage => 'Всё готово! Начните загружать любимую музыку прямо сейчас.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Полное сканирование'; @@ -3041,10 +2100,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3326,43 +2381,15 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Выберите режим'; + String downloadedAlbumDownloadedCount(int count) { + return '$count скачано'; + } @override - String get setupModeSelectionDescription => - 'Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'Загрузчик'; - - @override - String get setupModeDownloaderFeature1 => - 'Скачивайте треки в качестве FLAC без потерь'; - - @override - String get setupModeDownloaderFeature2 => - 'Сохраняйте музыку на устройство для прослушивания офлайн'; - - @override - String get setupModeDownloaderFeature3 => - 'Управляйте своей локальной музыкальной библиотекой'; - - @override - String get setupModeStreamingTitle => 'Стриминг'; - - @override - String get setupModeStreamingFeature1 => - 'Слушайте треки мгновенно без скачивания'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue автоматически подбирает новую музыку для вас'; - - @override - String get setupModeStreamingFeature3 => - 'Воспроизводите любой трек по запросу с элементами управления'; - - @override - String get setupModeChangeableLater => - 'Вы можете переключаться между режимами в любое время в Настройках.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Папки исполнителя используют только трек исполнителя'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 164833ef..bcc77961 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -11,19 +11,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; - @override String get navHome => 'Ara'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'Geçmiş'; - @override String get navSettings => 'Ayarlar'; @@ -33,14 +26,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get homeTitle => 'Ara'; - @override - String get homeSearchHint => 'Spotify URL\'i yapıştır veya ara...'; - - @override - String homeSearchHintExtension(String extensionName) { - return '$extensionName ile arat...'; - } - @override String get homeSubtitle => 'Spotify linki yapıştır veya isimle arat'; @@ -51,17 +36,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get homeRecent => 'En son'; - @override - String get historyTitle => 'Geçmiş'; - - @override - String historyDownloading(int count) { - return '($count) tane indiriliyor'; - } - - @override - String get historyDownloaded => 'İndirildi'; - @override String get historyFilterAll => 'Tümü'; @@ -71,48 +45,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get historyFilterSingles => 'Single\'lar'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count şarkı', - one: '1 şarkı', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albüm', - one: '1 albüm', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'İndirme geçmişi yok'; - - @override - String get historyNoDownloadsSubtitle => - 'İndirilen şarkılar burada gözükecek'; - - @override - String get historyNoAlbums => 'İndirilen albüm yok'; - - @override - String get historyNoAlbumsSubtitle => - 'Albümleri burada görmek için bir albümden birden fazla şarkı indir'; - - @override - String get historyNoSingles => 'Single indirilmemiş'; - - @override - String get historyNoSinglesSubtitle => 'Single şarkılar burada gözükecek'; - @override String get historySearchHint => 'Arama geçmişi...'; @@ -137,29 +69,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadTitle => 'İndirme'; - @override - String get downloadLocation => 'İndirme Konumu'; - - @override - String get downloadLocationSubtitle => - 'Dosyaları nereye kaydedeceğinizi seçin'; - - @override - String get downloadLocationDefault => 'Varsayılan dizin'; - - @override - String get downloadDefaultService => 'Varsayılan Hizmet'; - - @override - String get downloadDefaultServiceSubtitle => - 'İndirmeler için kullanılan hizmet'; - - @override - String get downloadDefaultQuality => 'Varsayılan Kalite'; - - @override - String get downloadAskQuality => 'İndirmeden Önce Kaliteyi Sor'; - @override String get downloadAskQualitySubtitle => 'Her indirmeden önce kalite seçim ekranını göster'; @@ -170,31 +79,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadFolderOrganization => 'Dosya Organizasyonu'; - @override - String get downloadSeparateSingles => 'Single\'ları Ayır'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Single şarkıları ayrı dosyaya koy'; - - @override - String get qualityBest => 'Mevcut en iyi'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Görünüm'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistem'; @@ -211,9 +98,6 @@ class AppLocalizationsTr extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Duvar kağıdının renklerini kullan'; - @override - String get appearanceAccentColor => 'Vurgu Rengi'; - @override String get appearanceHistoryView => 'Geçmiş Düzeni'; @@ -226,9 +110,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get optionsTitle => 'Seçenekler'; - @override - String get optionsSearchSource => 'Arama Kaynağı'; - @override String get optionsPrimaryProvider => 'Ana Kaynek'; @@ -252,33 +133,6 @@ class AppLocalizationsTr extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'İndirme başarısız olursa diğer hizmetleri dene'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Eklenti sağlayıcılarını kullan'; @@ -382,18 +236,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get extensionsTitle => 'Eklentiler'; - @override - String get extensionsInstalled => 'Kurulu Eklentiler'; - - @override - String get extensionsNone => 'Hiçbir eklenti kurulmamış'; - - @override - String get extensionsNoneSubtitle => 'Dükkan sekmesinden eklenti indir'; - - @override - String get extensionsEnabled => 'Etkin'; - @override String get extensionsDisabled => 'Devre Dışı'; @@ -410,9 +252,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get extensionsUninstall => 'Kaldır'; - @override - String get extensionsSetAsSearch => 'Arama Sağlayıcı olarak Ayarla'; - @override String get storeTitle => 'Eklenti Dükkanı'; @@ -488,9 +327,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get aboutSocial => 'Sosyal ağlar'; - @override - String get aboutSupport => 'Destek'; - @override String get aboutApp => 'Uygulama'; @@ -509,13 +345,6 @@ class AppLocalizationsTr extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazom Music indirmeleri için harika bir API. Ücretsiz yaptığın için teşekkürler!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -534,32 +363,6 @@ class AppLocalizationsTr extends AppLocalizations { String get aboutAppDescription => 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; - @override - String get albumTitle => 'Albüm'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count şarkı', - one: '1 şarkı', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Tümünü İndir'; - - @override - String get albumDownloadRemaining => 'Kalanını İndir'; - - @override - String get playlistTitle => 'Çalma Listesi'; - - @override - String get artistTitle => 'Sanatçı'; - @override String get artistAlbums => 'Albümler'; @@ -569,17 +372,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get artistCompilations => 'Derlemeler'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count yayın', - one: '1 yayın', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popüler'; @@ -588,27 +380,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Aylık $count dinleyici'; } - @override - String get trackMetadataTitle => 'Şarkı Bilgisi'; - - @override - String get trackMetadataArtist => 'Sanatçı'; - - @override - String get trackMetadataAlbum => 'Albüm'; - - @override - String get trackMetadataDuration => 'Süre'; - - @override - String get trackMetadataQuality => 'Kalite'; - - @override - String get trackMetadataPath => 'Dosya Yolu'; - - @override - String get trackMetadataDownloadedAt => 'İndirme tarihi'; - @override String get trackMetadataService => 'Hizmet'; @@ -621,53 +392,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackMetadataDelete => 'Sil'; - @override - String get trackMetadataRedownload => 'Yeniden İndir'; - - @override - String get trackMetadataOpenFolder => 'Klasörü Aç'; - - @override - String get setupTitle => 'SpotiFLAC\'e Hoşgeldiniz'; - - @override - String get setupSubtitle => 'Hadi başlayalım'; - - @override - String get setupStoragePermission => 'Depolama İzni'; - - @override - String get setupStoragePermissionSubtitle => - 'İndirilen dosyaları kaydetmek için gerekli'; - - @override - String get setupStoragePermissionGranted => 'İzin verildi'; - - @override - String get setupStoragePermissionDenied => 'İzin reddedildi'; - @override String get setupGrantPermission => 'İzin Ver'; - @override - String get setupDownloadLocation => 'İndirme Konumu'; - - @override - String get setupChooseFolder => 'Klasör Seç'; - - @override - String get setupContinue => 'Devam'; - @override String get setupSkip => 'Şimdilik atla'; @override String get setupStorageAccessRequired => 'Depolama Erişimi Gerekli'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC\'ın şarkıları seçili klasörünüze kaydetmek için \"Bütün dosyalara eriş\" iznine ihtiyacı var.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11 ve sonrasında şarkıların seçili klasörünüze kaydedilebilmesi için \"Bütün dosyalara eriş\" iznine ihtiyaç var.'; @@ -689,9 +422,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'En iyi deneyim için $permissionType izni zorunludur. Bunu ayarlardan daha sonra değiştirebilirsiniz.'; } - @override - String get setupSelectDownloadFolder => 'İndirilecek Klasörü Seç'; - @override String get setupUseDefaultFolder => 'Varsayılan Klasörü Kullan?'; @@ -733,21 +463,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin'; - @override - String get setupStepStorage => 'Depolama'; - - @override - String get setupStepNotification => 'Bildirim'; - - @override - String get setupStepFolder => 'Klasör'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'İzin'; - @override String get setupStorageGranted => 'Depolama İzni Verildi!'; @@ -764,13 +479,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupNotificationEnable => 'Bildirimleri Etkinleştir'; - @override - String get setupNotificationDescription => - 'İndirmeler bittiğinde veya bakılması gereken bir şey olduğunda haberdar olun.'; - - @override - String get setupFolderSelected => 'İndirilecek Klasör Seçildi!'; - @override String get setupFolderChoose => 'İndirilecek Klasörü Seç'; @@ -778,48 +486,12 @@ class AppLocalizationsTr extends AppLocalizations { String get setupFolderDescription => 'İndirdiğin şarkıların kaydedileceği klasörü seç.'; - @override - String get setupChangeFolder => 'Klasörü Değiştir'; - @override String get setupSelectFolder => 'Klasör Seç'; - @override - String get setupSpotifyApiOptional => 'Spotify API (İsteğe Bağlı)'; - - @override - String get setupSpotifyApiDescription => - 'Daha iyi arama sonuçları ve Spotify\'a özel içeriklere erişmek için Spotify API kimlik bilgilerini gir.'; - - @override - String get setupUseSpotifyApi => 'Spotify API\'ı kullan'; - - @override - String get setupEnterCredentialsBelow => 'Kimlik bilgilerini aşağıya gir'; - - @override - String get setupUsingDeezer => 'Deezer kullanılıyor (hesap gerekli değil)'; - - @override - String get setupEnterClientId => 'Spotify Client ID gir'; - - @override - String get setupEnterClientSecret => 'Spotify Client Secret gir'; - - @override - String get setupGetFreeCredentials => - 'Spotify Developer Dashboard\'tan API kimlik bilgilerini ücretsiz alın.'; - @override String get setupEnableNotifications => 'Bildirimleri Etkinleştir'; - @override - String get setupProceedToNextStep => 'Bir sonraki adıma geçebilirsin.'; - - @override - String get setupNotificationProgressDescription => - 'İndirme ilerlemelerinin bildirimlerini alacaksın.'; - @override String get setupNotificationBackgroundDescription => 'İndirmelerin durumu hakkında bildirim al. Bunu açmak uygulama arka plandayken indirmelerinizi takip etmenizi sağlar.'; @@ -827,32 +499,19 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupSkipForNow => 'Şimdilik atla'; - @override - String get setupBack => 'Geri'; - @override String get setupNext => 'Sıradaki'; @override String get setupGetStarted => 'Başla'; - @override - String get setupSkipAndStart => 'Kurulumu atla'; - @override String get setupAllowAccessToManageFiles => 'Lütfen bir sonraki ekranda \"Bütün dosyalara eriş\" iznini sağlayın.'; - @override - String get setupGetCredentialsFromSpotify => - 'Kimlik bilgilerini developer.spotify.com\'dan alın'; - @override String get dialogCancel => 'İptal'; - @override - String get dialogOk => 'Tamam'; - @override String get dialogSave => 'Kaydet'; @@ -862,21 +521,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get dialogRetry => 'Yeniden dene'; - @override - String get dialogClose => 'Kapat'; - - @override - String get dialogYes => 'Evet'; - - @override - String get dialogNo => 'Hayır'; - @override String get dialogClear => 'Temizle'; - @override - String get dialogConfirm => 'Onayla'; - @override String get dialogDone => 'Tamamlandı'; @@ -899,28 +546,9 @@ class AppLocalizationsTr extends AppLocalizations { String get dialogUnsavedChanges => 'Kaydedilmeyen değişiklikler mevcut. Bu değişiklikleri iptal etmek istiyor musunuz?'; - @override - String get dialogDownloadFailed => 'İndirme Başarısız'; - - @override - String get dialogTrackLabel => 'Şarkı:'; - - @override - String get dialogArtistLabel => 'Sanatçı:'; - - @override - String get dialogErrorLabel => 'Hata:'; - @override String get dialogClearAll => 'Tümünü Temizle'; - @override - String get dialogClearAllDownloads => - 'Bütün indirmeleri temizlemek istediğinize emin misiniz?'; - - @override - String get dialogRemoveFromDevice => 'Cihazdan kaldır?'; - @override String get dialogRemoveExtension => 'Eklentiyi Kaldır'; @@ -1021,11 +649,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get snackbarViewQueue => 'Kuyruğu Görüntüle'; - @override - String snackbarFailedToLoad(String error) { - return 'Yüklenemedi: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform Bağlantı panoya kopyalandı'; @@ -1067,44 +690,14 @@ class AppLocalizationsTr extends AppLocalizations { String get errorRateLimitedMessage => 'Çok fazla istek. Lütfen arama yapmadan önce biraz bekleyin.'; - @override - String errorFailedToLoad(String item) { - return '$item yüklenirken hata oluştu'; - } - @override String get errorNoTracksFound => 'Parça bulunamadı'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return '$item yüklenemedi: Eksik eklenti kaynağı'; } - @override - String get statusQueued => 'Sıraya alındı'; - - @override - String get statusDownloading => 'İndiriliyor'; - - @override - String get statusFinalizing => 'Tamamlanıyor'; - - @override - String get statusCompleted => 'Tamamlandı'; - - @override - String get statusFailed => 'Başarısız'; - - @override - String get statusSkipped => 'Atlandı'; - - @override - String get statusPaused => 'Durduruldu'; - @override String get actionPause => 'Duraklat'; @@ -1114,24 +707,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get actionCancel => 'Vazgeç'; - @override - String get actionStop => 'Durdur'; - - @override - String get actionSelect => 'Seç'; - @override String get actionSelectAll => 'Tümünü Seç'; @override String get actionDeselect => 'Seçimi kaldır'; - @override - String get actionPaste => 'Yapıştır'; - - @override - String get actionImportCsv => 'CSV İçe Aktar'; - @override String get actionRemoveCredentials => 'Özellikleri kaldır'; @@ -1146,20 +727,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get selectionAllSelected => 'Tüm parçalar seçildi'; - @override - String get selectionTapToSelect => 'Seçmek için parçalara dokunun'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'şarkıyı', - one: 'şarkıyı', - ); - return '$count $_temp0 sil'; - } - @override String get selectionSelectToDelete => 'Silinecek parçaları seçin'; @@ -1186,40 +753,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tooltipPlay => 'Oynat'; - @override - String get tooltipCancel => 'Vazgeç'; - - @override - String get tooltipStop => 'Durdur'; - - @override - String get tooltipRetry => 'Yeniden dene'; - - @override - String get tooltipRemove => 'Kaldır'; - - @override - String get tooltipClear => 'Temizle'; - - @override - String get tooltipPaste => 'Yapıştır'; - @override String get filenameFormat => 'Dosya adı formatı'; - @override - String filenameFormatPreview(String preview) { - return 'Önizleme: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Kullanılabilir yer tutucular:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1227,9 +763,6 @@ class AppLocalizationsTr extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Klasör Organizasyonu'; - @override String get folderOrganizationNone => 'Organizasyon yok'; @@ -1264,20 +797,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get updateAvailable => 'Güncelleme Mevcut'; - @override - String updateNewVersion(String version) { - return '$version sürümü mevcut'; - } - - @override - String get updateDownload => 'İndir'; - @override String get updateLater => 'Daha Sonra'; - @override - String get updateChangelog => 'Değişiklikler'; - @override String get updateStartingDownload => 'İndirme başlıyor...'; @@ -1308,13 +830,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get updateDontRemind => 'Bir daha sorma'; - @override - String get providerPriority => 'İndirme hizmetleri öncelik sırası'; - - @override - String get providerPrioritySubtitle => - 'İndirme hizmetlerini sıralamak için kaydır'; - @override String get providerPriorityTitle => 'İndirme hizmetleri öncelik sırası'; @@ -1332,13 +847,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get providerExtension => 'Eklenti'; - @override - String get metadataProviderPriority => 'Metadata Sağlayıcı Önceliği'; - - @override - String get metadataProviderPrioritySubtitle => - 'Şarkı metadata\'sı alınırken kullanılan sıra'; - @override String get metadataProviderPriorityTitle => 'Metadata Önceliği'; @@ -1359,18 +867,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get logTitle => 'Kayıtlar'; - @override - String get logCopy => 'Kayıtları Kopyala'; - - @override - String get logClear => 'Kayıtları temizle'; - - @override - String get logShare => 'Kayıtları Paylaş'; - - @override - String get logEmpty => 'Henüz kayıt yok'; - @override String get logCopied => 'Kayıtlar panoya kopyalandı'; @@ -1396,18 +892,6 @@ class AppLocalizationsTr extends AppLocalizations { String get logClearLogsMessage => 'Tüm kayıtları temizlemek istediğinize emin misiniz?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Kayıtları önem derecesine göre filtrele'; @@ -1418,48 +902,6 @@ class AppLocalizationsTr extends AppLocalizations { String get logNoLogsYetSubtitle => 'Uygulamayı kullandıkça kayıtlar burada görünecek'; - @override - String get logIssueSummary => 'Sorun Özeti'; - - @override - String get logIspBlockingDescription => - 'İnternet sağlayıcınız indirme hizmetlerine erişimi engelliyor olabilir'; - - @override - String get logIspBlockingSuggestion => - 'VPN kullanmayı veya DNS\'i 1.1.1.1 ya da 8.8.8.8 olarak değiştirmeyi deneyin'; - - @override - String get logRateLimitedDescription => 'Hizmete çok fazla istek gönderildi'; - - @override - String get logRateLimitedSuggestion => - 'Tekrar denemeden önce birkaç dakika bekleyin'; - - @override - String get logNetworkErrorDescription => 'Bağlantı sorunları tespit edildi'; - - @override - String get logNetworkErrorSuggestion => 'İnternet bağlantınızı kontrol edin'; - - @override - String get logTrackNotFoundDescription => - 'Bazı şarkılar indirme hizmetlerinde bulunamadı'; - - @override - String get logTrackNotFoundSuggestion => - 'Şarkı kayıpsız kalitede mevcut olmayabilir'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1567,9 +1009,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get appearanceLanguage => 'Uygulama Dili'; - @override - String get appearanceLanguageSubtitle => 'Tercih ettiğiniz dili seçin'; - @override String get settingsAppearanceSubtitle => 'Tema, renkler, görünüm'; @@ -1593,19 +1032,11 @@ class AppLocalizationsTr extends AppLocalizations { @override String get pressBackAgainToExit => 'Çıkmak için tekrar geri basın'; - @override - String get tracksHeader => 'Şarkılar'; - @override String downloadAllCount(int count) { return 'Tümünü İndir ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1715,11 +1146,6 @@ class AppLocalizationsTr extends AppLocalizations { String get trackDeleteConfirmMessage => 'Bu işlem indirilen dosyayı kalıcı olarak silecek ve geçmişten kaldıracaktır.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Bugün'; @@ -1741,18 +1167,6 @@ class AppLocalizationsTr extends AppLocalizations { return '$count ay önce'; } - @override - String get concurrentSequential => 'Sıralı'; - - @override - String get concurrentParallel2 => '2 Paralel'; - - @override - String get concurrentParallel3 => '3 Paralel'; - - @override - String get tapToSeeError => 'Hata detaylarını görmek için dokun'; - @override String get storeFilterAll => 'Tümü'; @@ -1774,15 +1188,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get storeClearFilters => 'Filtreleri temizle'; - @override - String get storeNoResults => 'Eklenti bulunamadı'; - - @override - String get extensionProviderPriority => 'Sağlayıcı Önceliği'; - - @override - String get extensionInstallButton => 'Eklenti Yükle'; - @override String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)'; @@ -1934,38 +1339,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1980,24 +1353,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -2013,14 +1368,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2032,78 +1379,18 @@ class AppLocalizationsTr extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2111,19 +1398,6 @@ class AppLocalizationsTr extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2144,30 +1418,6 @@ class AppLocalizationsTr extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2214,14 +1464,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2252,9 +1494,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2278,23 +1517,12 @@ class AppLocalizationsTr extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2339,9 +1567,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return '$count şarkı kuyruğa eklendi'; @@ -2397,9 +1622,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2462,11 +1684,6 @@ class AppLocalizationsTr extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2557,21 +1774,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2581,11 +1783,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2611,72 +1808,6 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2703,18 +1834,6 @@ class AppLocalizationsTr extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2722,18 +1841,6 @@ class AppLocalizationsTr extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2794,9 +1901,6 @@ class AppLocalizationsTr extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2959,10 +2063,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3243,42 +2343,15 @@ class AppLocalizationsTr extends AppLocalizations { } @override - String get setupModeSelectionTitle => 'Modunuzu Seçin'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => - 'SpotiFLAC\'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar\'dan değiştirebilirsiniz.'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => 'İndirici'; - - @override - String get setupModeDownloaderFeature1 => - 'Kayıpsız FLAC kalitesinde parça indirin'; - - @override - String get setupModeDownloaderFeature2 => - 'Çevrimdışı dinlemek için müziği cihazınıza kaydedin'; - - @override - String get setupModeDownloaderFeature3 => 'Yerel müzik kütüphanenizi yönetin'; - - @override - String get setupModeStreamingTitle => 'Yayın Akışı'; - - @override - String get setupModeStreamingFeature1 => - 'İndirmeden parçaları anında yayınlayın'; - - @override - String get setupModeStreamingFeature2 => - 'Smart Queue sizin için otomatik olarak yeni müzik keşfeder'; - - @override - String get setupModeStreamingFeature3 => - 'İstediğiniz parçayı oynatma kontrolleriyle çalın'; - - @override - String get setupModeChangeableLater => - 'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f0b5c82b..433e771b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -11,19 +11,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -248,33 +131,6 @@ class AppLocalizationsZh extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; - @override - String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOn => - 'Automatically skip to the next queue track when a stream cannot be resolved.'; - - @override - String get optionsAutoSkipUnavailableTracksSubtitleOff => - 'Stop on failed track resolution and show an error.'; - - @override - String get optionsInteractionMode => 'Interaction Mode'; - - @override - String get modeDownloader => 'Downloader Mode'; - - @override - String get modeDownloaderSubtitle => - 'Tap tracks to add them to download queue'; - - @override - String get modeStreaming => 'Streaming Mode'; - - @override - String get modeStreamingSubtitle => 'Tap tracks to play instantly'; - @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -377,18 +233,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -405,9 +249,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -481,9 +322,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -502,13 +340,6 @@ class AppLocalizationsZh extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -527,32 +358,6 @@ class AppLocalizationsZh extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -562,17 +367,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -581,27 +375,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -614,53 +387,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -682,9 +417,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -726,21 +458,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -757,13 +474,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -771,48 +481,12 @@ class AppLocalizationsZh extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -820,32 +494,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -855,21 +516,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -892,28 +541,9 @@ class AppLocalizationsZh extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -1014,11 +644,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1060,44 +685,14 @@ class AppLocalizationsZh extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; - @override - String get errorSeekNotSupported => - 'Seeking is not supported for this live stream'; - @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1107,24 +702,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1139,20 +722,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1179,40 +748,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1220,9 +758,6 @@ class AppLocalizationsZh extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1257,20 +792,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1301,12 +825,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1324,13 +842,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1351,18 +862,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1387,18 +886,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1408,48 +895,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1556,9 +1001,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1580,19 +1022,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; } - @override - String playAllCount(int count) { - return 'Play All ($count)'; - } - @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1701,11 +1135,6 @@ class AppLocalizationsZh extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1727,18 +1156,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1760,15 +1177,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1919,38 +1327,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1965,24 +1341,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override - String youtubeBitrateSubtitle(int bitrate, int min, int max) { - return '${bitrate}kbps ($min-$max)'; - } - - @override - String youtubeBitrateInputHelp(int min, int max) { - return 'Enter custom bitrate ($min-$max kbps)'; - } - - @override - String get youtubeBitrateFieldLabel => 'Bitrate (kbps)'; - - @override - String youtubeBitrateValidationError(int min, int max) { - return 'Bitrate must be between $min and $max kbps'; - } - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1998,14 +1356,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -2017,78 +1367,18 @@ class AppLocalizationsZh extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2096,19 +1386,6 @@ class AppLocalizationsZh extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2129,30 +1406,6 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2199,14 +1452,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2237,9 +1482,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2263,23 +1505,12 @@ class AppLocalizationsZh extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; - @override - String get discographyPlay => 'Play Discography'; - @override String get discographyDownloadAll => 'Download All'; - @override - String get discographyPlayAll => 'Play All'; - @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2324,9 +1555,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; - @override - String get discographyPlaySelected => 'Play Selected'; - @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -2382,9 +1610,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2447,11 +1672,6 @@ class AppLocalizationsZh extends AppLocalizations { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2542,21 +1762,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2566,11 +1771,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2596,72 +1796,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2688,18 +1822,6 @@ class AppLocalizationsZh extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2707,18 +1829,6 @@ class AppLocalizationsZh extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2779,9 +1889,6 @@ class AppLocalizationsZh extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2944,10 +2051,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -3228,37 +2331,17 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get setupModeSelectionTitle => '选择您的模式'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => '下载器'; - - @override - String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目'; - - @override - String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听'; - - @override - String get setupModeDownloaderFeature3 => '管理您的本地音乐库'; - - @override - String get setupModeStreamingTitle => '流媒体'; - - @override - String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目'; - - @override - String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐'; - - @override - String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目'; - - @override - String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -3268,19 +2351,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -3290,14 +2366,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -3307,17 +2375,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -3327,48 +2384,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -3393,27 +2408,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -3424,31 +2418,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -3464,9 +2436,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -3479,9 +2448,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -3607,18 +2573,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -3635,9 +2589,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -3711,9 +2662,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -3732,13 +2680,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -3757,32 +2698,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -3792,17 +2707,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -3811,27 +2715,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -3844,53 +2727,15 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -3912,9 +2757,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -3956,21 +2798,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -3987,13 +2814,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -4001,48 +2821,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -4050,32 +2834,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -4085,21 +2856,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -4122,28 +2881,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -4244,11 +2984,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -4290,11 +3025,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -4303,27 +3033,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -4333,24 +3042,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -4365,20 +3062,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -4405,43 +3088,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -4476,20 +3125,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -4520,12 +3158,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -4543,13 +3175,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -4570,18 +3195,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -4606,18 +3219,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -4627,48 +3228,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -4775,9 +3334,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -4799,9 +3355,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -4915,11 +3468,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -4941,18 +3489,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -4974,15 +3510,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -5133,38 +3660,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -5188,14 +3683,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -5207,78 +3694,18 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -5286,19 +3713,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -5319,30 +3733,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -5389,14 +3779,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -5427,9 +3809,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -5453,11 +3832,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -5563,9 +3937,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -5628,11 +3999,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -5712,21 +4078,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -5736,11 +4087,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -5766,72 +4112,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -5858,18 +4138,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -5877,18 +4145,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -5949,9 +4205,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -6114,10 +4367,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -6194,37 +4443,17 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get trackConvertFailed => 'Conversion failed'; @override - String get setupModeSelectionTitle => '选择您的模式'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => '下载器'; - - @override - String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目'; - - @override - String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听'; - - @override - String get setupModeDownloaderFeature3 => '管理您的本地音乐库'; - - @override - String get setupModeStreamingTitle => '流媒体'; - - @override - String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目'; - - @override - String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐'; - - @override - String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目'; - - @override - String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -6234,19 +4463,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -6256,14 +4478,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -6273,17 +4487,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get homeRecent => '最新的'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -6293,48 +4496,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -6359,27 +4520,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -6390,31 +4530,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -6430,9 +4548,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -6445,9 +4560,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -6573,18 +4685,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -6601,9 +4701,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -6677,9 +4774,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -6698,13 +4792,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -6723,32 +4810,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -6758,17 +4819,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -6777,27 +4827,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -6810,53 +4839,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -6878,9 +4869,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -6922,21 +4910,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -6953,13 +4926,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -6967,48 +4933,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -7016,32 +4946,19 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -7051,21 +4968,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -7088,28 +4993,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -7210,11 +5096,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -7256,11 +5137,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -7269,27 +5145,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -7299,24 +5154,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -7331,20 +5174,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -7371,43 +5200,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -7442,20 +5237,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -7486,12 +5270,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -7509,13 +5287,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -7536,18 +5307,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -7572,18 +5331,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -7593,48 +5340,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -7741,9 +5446,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -7765,9 +5467,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -7881,11 +5580,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -7907,18 +5601,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -7940,15 +5622,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -8099,38 +5772,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -8154,14 +5795,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -8173,78 +5806,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -8252,19 +5825,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -8285,30 +5845,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -8355,14 +5891,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -8393,9 +5921,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -8419,11 +5944,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -8529,9 +6049,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -8594,11 +6111,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -8678,21 +6190,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -8702,11 +6199,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -8732,72 +6224,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -8824,18 +6250,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -8843,18 +6257,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -8915,9 +6317,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -9080,10 +6479,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackReEnrich => 'Re-enrich'; - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; - @override String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; @@ -9160,35 +6555,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get trackConvertFailed => 'Conversion failed'; @override - String get setupModeSelectionTitle => '選擇您的模式'; + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } @override - String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。'; + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; @override - String get setupModeDownloaderTitle => '下載器'; - - @override - String get setupModeDownloaderFeature1 => '以無損 FLAC 品質下載曲目'; - - @override - String get setupModeDownloaderFeature2 => '將音樂儲存到裝置以供離線收聽'; - - @override - String get setupModeDownloaderFeature3 => '管理您的本機音樂庫'; - - @override - String get setupModeStreamingTitle => '串流'; - - @override - String get setupModeStreamingFeature1 => '無需下載即可即時串流曲目'; - - @override - String get setupModeStreamingFeature2 => 'Smart Queue 自動為您探索新音樂'; - - @override - String get setupModeStreamingFeature3 => '透過播放控制項隨時點播任意曲目'; - - @override - String get setupModeChangeableLater => '您可以隨時在設定中切換模式。'; + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 869d5559..b40c1f71 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Startseite", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Verlauf", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Einstellungen", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify-URL einfügen oder suchen...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Mit {extensionName} suchen...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify-Link einfügen oder nach Namen suchen", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Verlauf", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Wird heruntergeladen ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Heruntergeladen", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Alle", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 Album} other{{count} Alben}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Kein Download-Verlauf", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Heruntergeladene Titel werden hier angezeigt", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Keine Album-Downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Lade mehrere Titel eines Albums herunter, um sie hier zu sehen", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Keine Einzel-Downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Einzelne Titel-Downloads werden hier angezeigt", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Suchverlauf...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download-Speicherort", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Wähle den Speicherort der Dateien", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Standard-Speicherort", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Standard-Dienst", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Dienst für Downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Standard-Qualität", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Qualität vor Download abfragen", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Qualitätsauswahl für jeden Download anzeigen", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Singles trennen", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Einzelne Titel in separatem Ordner speichern", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Beste Qualität", - "@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": "Erscheinungsbild", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Design", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Akzentfarbe", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Verlaufsansicht", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Suchquelle", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primärer Anbieter", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installierte Erweiterungen", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Keine Erweiterungen installiert", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Erweiterungen aus dem Store-Tab installieren", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Aktiviert", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Deaktiviert", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Als Suchanbieter festlegen", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Erweiterungs-Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Wundervolle API für Amazon Musik-Downloads.", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Alle Herunterladen", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Downloads verbleibend", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Künstler", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Alben", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural,=1{1 Veröffentlichung} other{{count} Veröffentlichungen}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Beliebt", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Titel Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Künstler", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Länge", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Qualität", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Dateipfad", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Heruntergeladen", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Anbieter", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Erneut herunterladen", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Ordner öffnen", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Willkommen bei SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Los geht's", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Speicherberechtigung", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Berechtigung erteilt", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Berechtigung verweigert", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Berechtigung erlauben", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Speicherort", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Ordner wählen", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Fortfahren", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Vorerst überspringen", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Wähle Download-Ordner aus", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Als Standardordner verwenden?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Speicherort", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Benachrichtigung", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Ordner", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Berechtigung", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Speicherberechtigung erlaubt!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Ordner ausgewählt!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Speicherort auwählen", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Ordner ändern", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Ordner wählen", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify-API (optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Füge deine Spotify-API-Zugangsdaten für bessere Suchergebnisse und den Zugriff auf Spotify-exklusive Inhalte hinzu.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Spotify-API verwenden", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Gib deine Anmeldedaten unten ein", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Deezer verwenden (kein Konto erforderlich)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Spotify-Client-ID eingeben", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Spotify Client-Secret eingeben", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Hole dir kostenlose API-Anmeldeinformationen aus dem Spotify-Entwickler-Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Benachrichtigungen aktivieren", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Du kannst mit dem nächsten Schritt fortfahren.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Du erhältst Benachrichtigungen über den Download-Fortschritt.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Zurück", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Weiter", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Überspringen & Starten", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Bitte aktiviere \"Zugriff auf alle Dateien erlauben\" auf dem nächsten Bildschirm.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Zugangsdaten von developer.spotify.com erhalten", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Abbrechen", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Speichern", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Schließen", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Ja", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Nein", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Leeren", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Bestätigen", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Fertig", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download fehlgeschlagen", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Titel:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Künstler:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Fehler:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Alles löschen", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Bist du dir sicher, dass du alle Downloads löschen möchten?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Vom Gerät entfernen?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Erweiterung entfernen", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Fehler beim Laden: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL in die Zwischenablage kopiert", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Fehler beim Laden von: {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Keine Titel gefunden", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "In der Warteschlange", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Wird heruntergeladen", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Wird fertiggestellt", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Beendet", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Fehlgeschlagen", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Übersprungen", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Pausiert", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Beenden", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Wähle", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Alles Auswählen", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Einfügen", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "CSV-Datei importieren", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Anmeldedaten entfernen", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tippe auf Titel zum Auswählen", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Lösche {count} {count, plural, one {Titel}other{Titel}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Titel zum Löschen auswählen", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Abbrechen", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Beenden", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Wiederholen", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Entfernen", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Leeren", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Einfügen", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Dateinamenformat", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Vorschau: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Verfügbare Platzhalter:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Ordnerstruktur", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Keine Organisation", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} ist verfügbar", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Herunterladen", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Später", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Änderungsverlauf", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Download wird gestartet...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Anbieterpriorität", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Ziehen, um Download-Anbieter neu anzuordnen", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Anbieterpriorität", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Priorität des Metadaten-Anbieters", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Reihenfolge beim Abrufen von Titelmetadaten", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadaten Priorität", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Protokolle kopieren", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Protokolle löschen", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Protokolle teilen", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Keine Protokolle bisher", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Protokolle in Zwischenablage kopiert", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKIERUNG ERKANNT", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "LIMIT ERKANNT", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETZWERKFEHLER", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TITEL NICHT GEFUNDEN", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Protokolle nach Schweregrad filtern", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Problemübersicht", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Ihr ISP blockiert möglicherweise den Zugriff auf den Download Dienst", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Versuche es einem VPN oder ändere DNS auf 1.1.1.1 oder 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Zu viele Anfragen an den Dienst", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Warte ein paar Minuten, bevor du es erneut versuchst", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Verbindungsprobleme erkannt", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Überprüfe deine Internetverbindung", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Einige Titel konnten auf Download-Diensten nicht gefunden werden", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Der Titel ist möglicherweise nicht in verlustfreier Qualität verfügbar", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Gesamte Fehler: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Betroffen: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Einträge ({count} gefiltert)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Wähle deinen Modus", - "setupModeSelectionDescription": "Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.", - "setupModeDownloaderTitle": "Downloader", - "setupModeDownloaderFeature1": "Lade Titel in verlustfreier FLAC-Qualität herunter", - "setupModeDownloaderFeature2": "Speichere Musik auf deinem Gerät zum Offline-Hören", - "setupModeDownloaderFeature3": "Verwalte deine lokale Musikbibliothek", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen", - "setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich", - "setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung", - "setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 709c1213..ca416fa0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,897 +1,996 @@ { "@@locale": "en", "@@last_modified": "2026-01-16", - "appName": "SpotiFLAC", - "@appName": {"description": "App name - DO NOT TRANSLATE"}, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": {"description": "App description shown in about page"}, - + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, "navHome": "Home", - "@navHome": {"description": "Bottom navigation - Home tab"}, + "@navHome": { + "description": "Bottom navigation - Home tab" + }, "navLibrary": "Library", - "@navLibrary": {"description": "Bottom navigation - Library tab"}, - "navHistory": "History", - "@navHistory": {"description": "Bottom navigation - History tab (legacy)"}, + "@navLibrary": { + "description": "Bottom navigation - Library tab" + }, "navSettings": "Settings", - "@navSettings": {"description": "Bottom navigation - Settings tab"}, + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, "navStore": "Store", - "@navStore": {"description": "Bottom navigation - Extension store tab"}, - + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, "homeTitle": "Home", - "@homeTitle": {"description": "Home screen title"}, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": {"description": "Placeholder text in search box"}, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": {"type": "String", "description": "Name of the active extension"} - } + "@homeTitle": { + "description": "Home screen title" }, "homeSubtitle": "Paste a Spotify link or search by name", - "@homeSubtitle": {"description": "Subtitle shown below search box"}, + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", - "@homeSupports": {"description": "Info text about supported URL types"}, + "@homeSupports": { + "description": "Info text about supported URL types" + }, "homeRecent": "Recent", - "@homeRecent": {"description": "Section header for recent searches"}, - - "historyTitle": "History", - "@historyTitle": {"description": "History screen title"}, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": {"type": "int", "description": "Number of active downloads"} - } + "@homeRecent": { + "description": "Section header for recent searches" }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": {"description": "Tab showing completed downloads"}, "historyFilterAll": "All", - "@historyFilterAll": {"description": "Filter chip - show all items"}, + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, "historyFilterAlbums": "Albums", - "@historyFilterAlbums": {"description": "Filter chip - show albums only"}, + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, "historyFilterSingles": "Singles", - "@historyFilterSingles": {"description": "Filter chip - show singles only"}, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": {"type": "int"} - } + "@historyFilterSingles": { + "description": "Filter chip - show singles only" }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": {"type": "int"} - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": {"description": "Empty state title"}, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": {"description": "Empty state subtitle"}, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": {"description": "Empty state when filtering albums"}, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"}, - "historyNoSingles": "No single downloads", - "@historyNoSingles": {"description": "Empty state when filtering singles"}, -"historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"}, "historySearchHint": "Search history...", - "@historySearchHint": {"description": "Search bar placeholder in history"}, - + "@historySearchHint": { + "description": "Search bar placeholder in history" + }, "settingsTitle": "Settings", - "@settingsTitle": {"description": "Settings screen title"}, + "@settingsTitle": { + "description": "Settings screen title" + }, "settingsDownload": "Download", - "@settingsDownload": {"description": "Settings section - download options"}, + "@settingsDownload": { + "description": "Settings section - download options" + }, "settingsAppearance": "Appearance", - "@settingsAppearance": {"description": "Settings section - visual customization"}, + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, "settingsOptions": "Options", - "@settingsOptions": {"description": "Settings section - app options"}, + "@settingsOptions": { + "description": "Settings section - app options" + }, "settingsExtensions": "Extensions", - "@settingsExtensions": {"description": "Settings section - extension management"}, + "@settingsExtensions": { + "description": "Settings section - extension management" + }, "settingsAbout": "About", - "@settingsAbout": {"description": "Settings section - app info"}, - + "@settingsAbout": { + "description": "Settings section - app info" + }, "downloadTitle": "Download", - "@downloadTitle": {"description": "Download settings page title"}, - "downloadLocation": "Download Location", - "@downloadLocation": {"description": "Setting for download folder"}, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": {"description": "Subtitle for download location"}, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": {"description": "Shown when using default folder"}, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": {"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"}, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": {"description": "Subtitle for default service"}, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": {"description": "Setting for audio quality"}, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": {"description": "Toggle to show quality picker"}, + "@downloadTitle": { + "description": "Download settings page title" + }, "downloadAskQualitySubtitle": "Show quality picker for each download", - "@downloadAskQualitySubtitle": {"description": "Subtitle for ask quality toggle"}, + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, "downloadFilenameFormat": "Filename Format", - "@downloadFilenameFormat": {"description": "Setting for output filename pattern"}, + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, "downloadFolderOrganization": "Folder Organization", - "@downloadFolderOrganization": {"description": "Setting for folder structure"}, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": {"description": "Toggle to separate single tracks"}, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": {"description": "Subtitle for separate singles toggle"}, - - "qualityBest": "Best Available", - "@qualityBest": {"description": "Audio quality option - highest available"}, - "qualityFlac": "FLAC", - "@qualityFlac": {"description": "Audio quality option - FLAC lossless"}, - "quality320": "320 kbps", - "@quality320": {"description": "Audio quality option - 320kbps MP3"}, - "quality128": "128 kbps", - "@quality128": {"description": "Audio quality option - 128kbps MP3"}, - + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, "appearanceTitle": "Appearance", - "@appearanceTitle": {"description": "Appearance settings page title"}, - "appearanceTheme": "Theme", - "@appearanceTheme": {"description": "Theme mode setting"}, + "@appearanceTitle": { + "description": "Appearance settings page title" + }, "appearanceThemeSystem": "System", - "@appearanceThemeSystem": {"description": "Follow system theme"}, + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, "appearanceThemeLight": "Light", - "@appearanceThemeLight": {"description": "Light theme"}, + "@appearanceThemeLight": { + "description": "Light theme" + }, "appearanceThemeDark": "Dark", - "@appearanceThemeDark": {"description": "Dark theme"}, + "@appearanceThemeDark": { + "description": "Dark theme" + }, "appearanceDynamicColor": "Dynamic Color", - "@appearanceDynamicColor": {"description": "Material You dynamic colors"}, + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", - "@appearanceDynamicColorSubtitle": {"description": "Subtitle for dynamic color"}, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": {"description": "Custom accent color picker"}, + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, "appearanceHistoryView": "History View", - "@appearanceHistoryView": {"description": "Layout style for history"}, + "@appearanceHistoryView": { + "description": "Layout style for history" + }, "appearanceHistoryViewList": "List", - "@appearanceHistoryViewList": {"description": "List layout option"}, + "@appearanceHistoryViewList": { + "description": "List layout option" + }, "appearanceHistoryViewGrid": "Grid", - "@appearanceHistoryViewGrid": {"description": "Grid layout option"}, - + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, "optionsTitle": "Options", - "@optionsTitle": {"description": "Options settings page title"}, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": {"description": "Section for search provider settings"}, + "@optionsTitle": { + "description": "Options settings page title" + }, "optionsPrimaryProvider": "Primary Provider", - "@optionsPrimaryProvider": {"description": "Main search provider setting"}, + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", - "@optionsPrimaryProviderSubtitle": {"description": "Subtitle for primary provider"}, + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, "optionsUsingExtension": "Using extension: {extensionName}", "@optionsUsingExtension": { "description": "Shows active extension name", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", - "@optionsSwitchBack": {"description": "Hint to switch back to built-in providers"}, + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, "optionsAutoFallback": "Auto Fallback", - "@optionsAutoFallback": {"description": "Auto-retry with other services"}, + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, "optionsAutoFallbackSubtitle": "Try other services if download fails", - "@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"}, - "optionsAutoSkipUnavailableTracks": "Auto Skip Unavailable Tracks", - "@optionsAutoSkipUnavailableTracks": {"description": "Toggle to skip to the next queue track when current track stream resolution fails"}, - "optionsAutoSkipUnavailableTracksSubtitleOn": "Automatically skip to the next queue track when a stream cannot be resolved.", - "@optionsAutoSkipUnavailableTracksSubtitleOn": {"description": "Subtitle when auto skip on resolve failure is enabled"}, - "optionsAutoSkipUnavailableTracksSubtitleOff": "Stop on failed track resolution and show an error.", - "@optionsAutoSkipUnavailableTracksSubtitleOff": {"description": "Subtitle when auto skip on resolve failure is disabled"}, - "optionsInteractionMode": "Interaction Mode", - "@optionsInteractionMode": {"description": "Tap behavior mode for track lists"}, - "modeDownloader": "Downloader Mode", - "@modeDownloader": {"description": "Interaction mode where taps queue downloads"}, - "modeDownloaderSubtitle": "Tap tracks to add them to download queue", - "@modeDownloaderSubtitle": {"description": "Subtitle for downloader interaction mode"}, - "modeStreaming": "Streaming Mode", - "@modeStreaming": {"description": "Interaction mode where taps start playback"}, - "modeStreamingSubtitle": "Tap tracks to play instantly", - "@modeStreamingSubtitle": {"description": "Subtitle for streaming interaction mode"}, + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, "optionsUseExtensionProviders": "Use Extension Providers", - "@optionsUseExtensionProviders": {"description": "Enable extension download providers"}, + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, "optionsUseExtensionProvidersOn": "Extensions will be tried first", - "@optionsUseExtensionProvidersOn": {"description": "Status when extension providers enabled"}, + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, "optionsUseExtensionProvidersOff": "Using built-in providers only", - "@optionsUseExtensionProvidersOff": {"description": "Status when extension providers disabled"}, + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, "optionsEmbedLyrics": "Embed Lyrics", - "@optionsEmbedLyrics": {"description": "Embed lyrics in audio files"}, + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", - "@optionsEmbedLyricsSubtitle": {"description": "Subtitle for embed lyrics"}, + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, "optionsMaxQualityCover": "Max Quality Cover", - "@optionsMaxQualityCover": {"description": "Download highest quality album art"}, + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", - "@optionsMaxQualityCoverSubtitle": {"description": "Subtitle for max quality cover"}, + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, "optionsConcurrentDownloads": "Concurrent Downloads", - "@optionsConcurrentDownloads": {"description": "Number of parallel downloads"}, + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, "optionsConcurrentSequential": "Sequential (1 at a time)", - "@optionsConcurrentSequential": {"description": "Download one at a time"}, + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, "optionsConcurrentParallel": "{count} parallel downloads", "@optionsConcurrentParallel": { "description": "Multiple parallel downloads", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", - "@optionsConcurrentWarning": {"description": "Warning about rate limits"}, + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, "optionsExtensionStore": "Extension Store", - "@optionsExtensionStore": {"description": "Show/hide store tab"}, + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, "optionsExtensionStoreSubtitle": "Show Store tab in navigation", - "@optionsExtensionStoreSubtitle": {"description": "Subtitle for extension store toggle"}, + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, "optionsCheckUpdates": "Check for Updates", - "@optionsCheckUpdates": {"description": "Auto update check toggle"}, + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, "optionsCheckUpdatesSubtitle": "Notify when new version is available", - "@optionsCheckUpdatesSubtitle": {"description": "Subtitle for update check"}, + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, "optionsUpdateChannel": "Update Channel", - "@optionsUpdateChannel": {"description": "Stable vs preview releases"}, + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, "optionsUpdateChannelStable": "Stable releases only", - "@optionsUpdateChannelStable": {"description": "Only stable updates"}, + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, "optionsUpdateChannelPreview": "Get preview releases", - "@optionsUpdateChannelPreview": {"description": "Include beta/preview updates"}, + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", - "@optionsUpdateChannelWarning": {"description": "Warning about preview channel"}, + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, "optionsClearHistory": "Clear Download History", - "@optionsClearHistory": {"description": "Delete all download history"}, + "@optionsClearHistory": { + "description": "Delete all download history" + }, "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", - "@optionsClearHistorySubtitle": {"description": "Subtitle for clear history"}, + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, "optionsDetailedLogging": "Detailed Logging", - "@optionsDetailedLogging": {"description": "Enable verbose logs for debugging"}, + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, "optionsDetailedLoggingOn": "Detailed logs are being recorded", - "@optionsDetailedLoggingOn": {"description": "Status when logging enabled"}, + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, "optionsDetailedLoggingOff": "Enable for bug reports", - "@optionsDetailedLoggingOff": {"description": "Status when logging disabled"}, + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, "optionsSpotifyCredentials": "Spotify Credentials", - "@optionsSpotifyCredentials": {"description": "Spotify API credentials setting"}, + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", "@optionsSpotifyCredentialsConfigured": { "description": "Shows configured client ID preview", "placeholders": { - "clientId": {"type": "String"} + "clientId": { + "type": "String" + } } }, "optionsSpotifyCredentialsRequired": "Required - tap to configure", - "@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"}, + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", - "@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"}, + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, "optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", - "@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"}, - + "@optionsSpotifyDeprecationWarning": { + "description": "Warning about Spotify API deprecation" + }, "extensionsTitle": "Extensions", - "@extensionsTitle": {"description": "Extensions page title"}, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": {"description": "Section header for installed extensions"}, - "extensionsNone": "No extensions installed", - "@extensionsNone": {"description": "Empty state title"}, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": {"description": "Empty state subtitle"}, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": {"description": "Extension status - active"}, + "@extensionsTitle": { + "description": "Extensions page title" + }, "extensionsDisabled": "Disabled", - "@extensionsDisabled": {"description": "Extension status - inactive"}, + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, "extensionsVersion": "Version {version}", "@extensionsVersion": { "description": "Extension version display", "placeholders": { - "version": {"type": "String"} + "version": { + "type": "String" + } } }, "extensionsAuthor": "by {author}", "@extensionsAuthor": { "description": "Extension author credit", "placeholders": { - "author": {"type": "String"} + "author": { + "type": "String" + } } }, "extensionsUninstall": "Uninstall", - "@extensionsUninstall": {"description": "Uninstall extension button"}, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": {"description": "Use extension for search"}, - - "storeTitle": "Extension Store", - "@storeTitle": {"description": "Store screen title"}, - "storeSearch": "Search extensions...", - "@storeSearch": {"description": "Store search placeholder"}, - "storeInstall": "Install", - "@storeInstall": {"description": "Install extension button"}, - "storeInstalled": "Installed", - "@storeInstalled": {"description": "Already installed badge"}, - "storeUpdate": "Update", - "@storeUpdate": {"description": "Update available button"}, - - "aboutTitle": "About", - "@aboutTitle": {"description": "About page title"}, - "aboutContributors": "Contributors", - "@aboutContributors": {"description": "Section for contributors"}, - "aboutMobileDeveloper": "Mobile version developer", - "@aboutMobileDeveloper": {"description": "Role description for mobile dev"}, - "aboutOriginalCreator": "Creator of the original SpotiFLAC", - "@aboutOriginalCreator": {"description": "Role description for original creator"}, - "aboutLogoArtist": "The talented artist who created our beautiful app logo!", - "@aboutLogoArtist": {"description": "Role description for logo artist"}, - "aboutTranslators": "Translators", - "@aboutTranslators": {"description": "Section for translators"}, - "aboutSpecialThanks": "Special Thanks", - "@aboutSpecialThanks": {"description": "Section for special thanks"}, - "aboutLinks": "Links", - "@aboutLinks": {"description": "Section for external links"}, - "aboutMobileSource": "Mobile source code", - "@aboutMobileSource": {"description": "Link to mobile GitHub repo"}, - "aboutPCSource": "PC source code", - "@aboutPCSource": {"description": "Link to PC GitHub repo"}, - "aboutReportIssue": "Report an issue", - "@aboutReportIssue": {"description": "Link to report bugs"}, - "aboutReportIssueSubtitle": "Report any problems you encounter", - "@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"}, -"aboutFeatureRequest": "Feature request", - "@aboutFeatureRequest": {"description": "Link to suggest features"}, - "aboutFeatureRequestSubtitle": "Suggest new features for the app", - "@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"}, - "aboutTelegramChannel": "Telegram Channel", - "@aboutTelegramChannel": {"description": "Link to Telegram channel"}, - "aboutTelegramChannelSubtitle": "Announcements and updates", - "@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"}, - "aboutTelegramChat": "Telegram Community", - "@aboutTelegramChat": {"description": "Link to Telegram chat group"}, - "aboutTelegramChatSubtitle": "Chat with other users", - "@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"}, - "aboutSocial": "Social", - "@aboutSocial": {"description": "Section for social links"}, - "aboutSupport": "Support", - "@aboutSupport": {"description": "Section for support/donation links"}, - "aboutApp": "App", - "@aboutApp": {"description": "Section for app info"}, - "aboutVersion": "Version", - "@aboutVersion": {"description": "Version info label"}, - "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", - "@aboutBinimumDesc": {"description": "Credit description for binimum"}, - "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", - "@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"}, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": {"description": "Credit description for sjdonado"}, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"}, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": {"description": "Credit for DoubleDouble API"}, - "aboutDabMusic": "DAB Music", - "@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"}, - "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", - "@aboutDabMusicDesc": {"description": "Credit for DAB Music API"}, - "aboutSpotiSaver": "SpotiSaver", - "@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"}, - "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", - "@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"}, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@aboutAppDescription": {"description": "App description in header card"}, - - "albumTitle": "Album", - "@albumTitle": {"description": "Album screen title"}, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": {"type": "int"} - } + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "storeTitle": "Extension Store", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Search extensions...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Install", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Installed", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Update", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "About", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Contributors", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Mobile version developer", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutTranslators": "Translators", + "@aboutTranslators": { + "description": "Section for translators" + }, + "aboutSpecialThanks": "Special Thanks", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Links", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Mobile source code", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "PC source code", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Report an issue", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Report any problems you encounter", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Feature request", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutTelegramChannel": "Telegram Channel", + "@aboutTelegramChannel": { + "description": "Link to Telegram channel" + }, + "aboutTelegramChannelSubtitle": "Announcements and updates", + "@aboutTelegramChannelSubtitle": { + "description": "Subtitle for Telegram channel" + }, + "aboutTelegramChat": "Telegram Community", + "@aboutTelegramChat": { + "description": "Link to Telegram chat group" + }, + "aboutTelegramChatSubtitle": "Chat with other users", + "@aboutTelegramChatSubtitle": { + "description": "Subtitle for Telegram chat" + }, + "aboutSocial": "Social", + "@aboutSocial": { + "description": "Section for social links" + }, + "aboutApp": "App", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Version", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", + "@aboutSjdonadoDesc": { + "description": "Credit description for sjdonado" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutSpotiSaver": "SpotiSaver", + "@aboutSpotiSaver": { + "description": "Name of SpotiSaver API service - DO NOT TRANSLATE" + }, + "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", + "@aboutSpotiSaverDesc": { + "description": "Credit for SpotiSaver API" + }, + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": {"description": "Button to download all tracks"}, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": {"description": "Button to download remaining tracks"}, - - "playlistTitle": "Playlist", - "@playlistTitle": {"description": "Playlist screen title"}, - "artistTitle": "Artist", - "@artistTitle": {"description": "Artist screen title"}, "artistAlbums": "Albums", - "@artistAlbums": {"description": "Section header for artist albums"}, + "@artistAlbums": { + "description": "Section header for artist albums" + }, "artistSingles": "Singles & EPs", - "@artistSingles": {"description": "Section header for singles/EPs"}, + "@artistSingles": { + "description": "Section header for singles/EPs" + }, "artistCompilations": "Compilations", - "@artistCompilations": {"description": "Section header for compilations"}, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": {"type": "int"} - } + "@artistCompilations": { + "description": "Section header for compilations" }, "artistPopular": "Popular", - "@artistPopular": {"description": "Section header for popular/top tracks"}, + "@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"} + "count": { + "type": "String", + "description": "Formatted listener count" + } } }, - - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": {"description": "Track metadata screen title"}, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": {"description": "Metadata field - artist name"}, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": {"description": "Metadata field - album name"}, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": {"description": "Metadata field - track length"}, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": {"description": "Metadata field - audio quality"}, - "trackMetadataPath": "File Path", - "@trackMetadataPath": {"description": "Metadata field - file location"}, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": {"description": "Metadata field - download date"}, "trackMetadataService": "Service", - "@trackMetadataService": {"description": "Metadata field - download service used"}, + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, "trackMetadataPlay": "Play", - "@trackMetadataPlay": {"description": "Action button - play track"}, + "@trackMetadataPlay": { + "description": "Action button - play track" + }, "trackMetadataShare": "Share", - "@trackMetadataShare": {"description": "Action button - share track"}, + "@trackMetadataShare": { + "description": "Action button - share track" + }, "trackMetadataDelete": "Delete", - "@trackMetadataDelete": {"description": "Action button - delete track"}, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": {"description": "Action button - download again"}, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": {"description": "Action button - open containing folder"}, - - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": {"description": "Setup wizard title"}, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": {"description": "Setup wizard subtitle"}, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": {"description": "Storage permission step title"}, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": {"description": "Explanation for storage permission"}, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": {"description": "Status when permission granted"}, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": {"description": "Status when permission denied"}, + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, "setupGrantPermission": "Grant Permission", - "@setupGrantPermission": {"description": "Button to request permission"}, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": {"description": "Download folder step title"}, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": {"description": "Button to pick folder"}, - "setupContinue": "Continue", - "@setupContinue": {"description": "Continue to next step button"}, + "@setupGrantPermission": { + "description": "Button to request permission" + }, "setupSkip": "Skip for now", - "@setupSkip": {"description": "Skip current step button"}, + "@setupSkip": { + "description": "Skip current step button" + }, "setupStorageAccessRequired": "Storage Access Required", - "@setupStorageAccessRequired": {"description": "Title when storage access needed"}, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": {"description": "Explanation for storage access"}, + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", - "@setupStorageAccessMessageAndroid11": {"description": "Android 11+ specific explanation"}, + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, "setupOpenSettings": "Open Settings", - "@setupOpenSettings": {"description": "Button to open system settings"}, + "@setupOpenSettings": { + "description": "Button to open system settings" + }, "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", - "@setupPermissionDeniedMessage": {"description": "Error when permission denied"}, + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, "setupPermissionRequired": "{permissionType} Permission Required", "@setupPermissionRequired": { "description": "Generic permission required title", "placeholders": { - "permissionType": {"type": "String", "description": "Type of permission (Storage/Notification)"} + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } } }, "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", "@setupPermissionRequiredMessage": { "description": "Generic permission required message", "placeholders": { - "permissionType": {"type": "String"} + "permissionType": { + "type": "String" + } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": {"description": "Folder selection step title"}, "setupUseDefaultFolder": "Use Default Folder?", - "@setupUseDefaultFolder": {"description": "Dialog title for default folder"}, + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", - "@setupNoFolderSelected": {"description": "Prompt when no folder selected"}, + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, "setupUseDefault": "Use Default", - "@setupUseDefault": {"description": "Button to use default folder"}, + "@setupUseDefault": { + "description": "Button to use default folder" + }, "setupDownloadLocationTitle": "Download Location", - "@setupDownloadLocationTitle": {"description": "Download location dialog title"}, + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, "setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", - "@setupDownloadLocationIosMessage": {"description": "iOS-specific folder info"}, + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, "setupAppDocumentsFolder": "App Documents Folder", - "@setupAppDocumentsFolder": {"description": "iOS documents folder option"}, + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", - "@setupAppDocumentsFolderSubtitle": {"description": "Subtitle for documents folder"}, + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, "setupChooseFromFiles": "Choose from Files", - "@setupChooseFromFiles": {"description": "iOS file picker option"}, + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, "setupChooseFromFilesSubtitle": "Select iCloud or other location", - "@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"}, -"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", - "@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"}, + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", - "@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"}, + "@setupIcloudNotSupported": { + "description": "Error when user selects iCloud Drive on iOS" + }, "setupDownloadInFlac": "Download Spotify tracks in FLAC", - "@setupDownloadInFlac": {"description": "App tagline in setup"}, - "setupStepStorage": "Storage", - "@setupStepStorage": {"description": "Setup step indicator - storage"}, - "setupStepNotification": "Notification", - "@setupStepNotification": {"description": "Setup step indicator - notification"}, - "setupStepFolder": "Folder", - "@setupStepFolder": {"description": "Setup step indicator - folder"}, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": {"description": "Setup step indicator - Spotify API"}, - "setupStepPermission": "Permission", - "@setupStepPermission": {"description": "Setup step indicator - permission"}, + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, "setupStorageGranted": "Storage Permission Granted!", - "@setupStorageGranted": {"description": "Success message for storage permission"}, + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, "setupStorageRequired": "Storage Permission Required", - "@setupStorageRequired": {"description": "Title when storage permission needed"}, + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", - "@setupStorageDescription": {"description": "Explanation for storage permission"}, + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, "setupNotificationGranted": "Notification Permission Granted!", - "@setupNotificationGranted": {"description": "Success message for notification permission"}, + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, "setupNotificationEnable": "Enable Notifications", - "@setupNotificationEnable": {"description": "Button to enable notifications"}, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": {"description": "Explanation for notifications"}, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": {"description": "Success message for folder selection"}, + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, "setupFolderChoose": "Choose Download Folder", - "@setupFolderChoose": {"description": "Button to choose folder"}, + "@setupFolderChoose": { + "description": "Button to choose folder" + }, "setupFolderDescription": "Select a folder where your downloaded music will be saved.", - "@setupFolderDescription": {"description": "Explanation for folder selection"}, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": {"description": "Button to change selected folder"}, + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, "setupSelectFolder": "Select Folder", - "@setupSelectFolder": {"description": "Button to select folder"}, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": {"description": "Spotify API step title"}, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": {"description": "Explanation for Spotify API"}, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": {"description": "Toggle to enable Spotify API"}, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": {"description": "Prompt to enter credentials"}, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": {"description": "Status when using Deezer"}, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": {"description": "Placeholder for client ID field"}, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": {"description": "Placeholder for client secret field"}, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": {"description": "Info about getting Spotify credentials"}, + "@setupSelectFolder": { + "description": "Button to select folder" + }, "setupEnableNotifications": "Enable Notifications", - "@setupEnableNotifications": {"description": "Button to enable notifications"}, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": {"description": "Message after completing a step"}, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": {"description": "Info about notification usage"}, + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", - "@setupNotificationBackgroundDescription": {"description": "Detailed notification explanation"}, + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, "setupSkipForNow": "Skip for now", - "@setupSkipForNow": {"description": "Skip button text"}, - "setupBack": "Back", - "@setupBack": {"description": "Back button text"}, + "@setupSkipForNow": { + "description": "Skip button text" + }, "setupNext": "Next", - "@setupNext": {"description": "Next button text"}, + "@setupNext": { + "description": "Next button text" + }, "setupGetStarted": "Get Started", - "@setupGetStarted": {"description": "Final setup button"}, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": {"description": "Skip setup and start app"}, + "@setupGetStarted": { + "description": "Final setup button" + }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", - "@setupAllowAccessToManageFiles": {"description": "Instruction for file access permission"}, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": {"description": "Link text for Spotify developer portal"}, - + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, "dialogCancel": "Cancel", - "@dialogCancel": {"description": "Dialog button - cancel action"}, - "dialogOk": "OK", - "@dialogOk": {"description": "Dialog button - confirm/acknowledge"}, + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, "dialogSave": "Save", - "@dialogSave": {"description": "Dialog button - save changes"}, + "@dialogSave": { + "description": "Dialog button - save changes" + }, "dialogDelete": "Delete", - "@dialogDelete": {"description": "Dialog button - delete item"}, + "@dialogDelete": { + "description": "Dialog button - delete item" + }, "dialogRetry": "Retry", - "@dialogRetry": {"description": "Dialog button - retry action"}, - "dialogClose": "Close", - "@dialogClose": {"description": "Dialog button - close dialog"}, - "dialogYes": "Yes", - "@dialogYes": {"description": "Dialog button - confirm yes"}, - "dialogNo": "No", - "@dialogNo": {"description": "Dialog button - confirm no"}, + "@dialogRetry": { + "description": "Dialog button - retry action" + }, "dialogClear": "Clear", - "@dialogClear": {"description": "Dialog button - clear items"}, - "dialogConfirm": "Confirm", - "@dialogConfirm": {"description": "Dialog button - confirm action"}, + "@dialogClear": { + "description": "Dialog button - clear items" + }, "dialogDone": "Done", - "@dialogDone": {"description": "Dialog button - action completed"}, + "@dialogDone": { + "description": "Dialog button - action completed" + }, "dialogImport": "Import", - "@dialogImport": {"description": "Dialog button - import data"}, + "@dialogImport": { + "description": "Dialog button - import data" + }, "dialogDiscard": "Discard", - "@dialogDiscard": {"description": "Dialog button - discard changes"}, + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, "dialogRemove": "Remove", - "@dialogRemove": {"description": "Dialog button - remove item"}, + "@dialogRemove": { + "description": "Dialog button - remove item" + }, "dialogUninstall": "Uninstall", - "@dialogUninstall": {"description": "Dialog button - uninstall extension"}, + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, "dialogDiscardChanges": "Discard Changes?", - "@dialogDiscardChanges": {"description": "Dialog title - unsaved changes warning"}, + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", - "@dialogUnsavedChanges": {"description": "Dialog message - unsaved changes"}, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": {"description": "Dialog title - download error"}, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": {"description": "Label for track name in error dialog"}, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": {"description": "Label for artist name in error dialog"}, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": {"description": "Label for error message"}, + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, "dialogClearAll": "Clear All", - "@dialogClearAll": {"description": "Dialog title - clear all items"}, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": {"description": "Dialog message - clear downloads confirmation"}, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": {"description": "Dialog title - delete file confirmation"}, + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, "dialogRemoveExtension": "Remove Extension", - "@dialogRemoveExtension": {"description": "Dialog title - uninstall extension"}, + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", - "@dialogRemoveExtensionMessage": {"description": "Dialog message - uninstall confirmation"}, + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, "dialogUninstallExtension": "Uninstall Extension?", - "@dialogUninstallExtension": {"description": "Dialog title - uninstall extension"}, + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", "@dialogUninstallExtensionMessage": { "description": "Dialog message - uninstall specific extension", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "dialogClearHistoryTitle": "Clear History", - "@dialogClearHistoryTitle": {"description": "Dialog title - clear download history"}, + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", - "@dialogClearHistoryMessage": {"description": "Dialog message - clear history confirmation"}, + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, "dialogDeleteSelectedTitle": "Delete Selected", - "@dialogDeleteSelectedTitle": {"description": "Dialog title - delete selected items"}, + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, "dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "dialogImportPlaylistTitle": "Import Playlist", - "@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"}, + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "csvImportTracks": "{count} tracks from CSV", "@csvImportTracks": { "description": "Label shown in quality picker for CSV import", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "@dialogImportPlaylistMessage": { "description": "Dialog message - import playlist confirmation", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "snackbarAddedToQueue": "Added \"{trackName}\" to queue", "@snackbarAddedToQueue": { "description": "Snackbar - track added to download queue", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "snackbarAddedTracksToQueue": "Added {count} tracks to queue", "@snackbarAddedTracksToQueue": { "description": "Snackbar - multiple tracks added to queue", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", "@snackbarAlreadyDownloaded": { "description": "Snackbar - track already exists", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", "@snackbarAlreadyInLibrary": { "description": "Snackbar - track already exists in local library", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "snackbarHistoryCleared": "History cleared", - "@snackbarHistoryCleared": {"description": "Snackbar - history deleted"}, + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, "snackbarCredentialsSaved": "Credentials saved", - "@snackbarCredentialsSaved": {"description": "Snackbar - Spotify credentials saved"}, + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, "snackbarCredentialsCleared": "Credentials cleared", - "@snackbarCredentialsCleared": {"description": "Snackbar - Spotify credentials removed"}, + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "snackbarCannotOpenFile": "Cannot open file: {error}", "@snackbarCannotOpenFile": { "description": "Snackbar - file open error", "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "snackbarFillAllFields": "Please fill all fields", - "@snackbarFillAllFields": {"description": "Snackbar - validation error"}, + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, "snackbarViewQueue": "View Queue", - "@snackbarViewQueue": {"description": "Snackbar action - view download queue"}, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": {"type": "String"} - } + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", "placeholders": { - "platform": {"type": "String", "description": "Platform name (Spotify/Deezer)"} + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } } }, "snackbarFileNotFound": "File not found", - "@snackbarFileNotFound": {"description": "Snackbar - file doesn't exist"}, + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, "snackbarSelectExtFile": "Please select a .spotiflac-ext file", - "@snackbarSelectExtFile": {"description": "Snackbar - wrong file type selected"}, + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, "snackbarProviderPrioritySaved": "Provider priority saved", - "@snackbarProviderPrioritySaved": {"description": "Snackbar - provider order saved"}, + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, "snackbarMetadataProviderSaved": "Metadata provider priority saved", - "@snackbarMetadataProviderSaved": {"description": "Snackbar - metadata provider order saved"}, + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, "snackbarExtensionInstalled": "{extensionName} installed.", "@snackbarExtensionInstalled": { "description": "Snackbar - extension installed successfully", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "snackbarExtensionUpdated": "{extensionName} updated.", "@snackbarExtensionUpdated": { "description": "Snackbar - extension updated successfully", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "snackbarFailedToInstall": "Failed to install extension", - "@snackbarFailedToInstall": {"description": "Snackbar - extension install error"}, + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, "snackbarFailedToUpdate": "Failed to update extension", - "@snackbarFailedToUpdate": {"description": "Snackbar - extension update error"}, - + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, "errorRateLimited": "Rate Limited", - "@errorRateLimited": {"description": "Error title - too many requests"}, + "@errorRateLimited": { + "description": "Error title - too many requests" + }, "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", - "@errorRateLimitedMessage": {"description": "Error message - rate limit explanation"}, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": {"type": "String", "description": "Item that failed to load (album/playlist/etc)"} - } + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" }, "errorNoTracksFound": "No tracks found", - "@errorNoTracksFound": {"description": "Error - search returned no results"}, - "errorSeekNotSupported": "Seeking is not supported for this live stream", - "@errorSeekNotSupported": {"description": "Error - seek disabled for live decrypted stream"}, + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, "errorMissingExtensionSource": "Cannot load {item}: missing extension source", "@errorMissingExtensionSource": { "description": "Error - extension source not available", "placeholders": { - "item": {"type": "String"} + "item": { + "type": "String" + } } }, - - "statusQueued": "Queued", - "@statusQueued": {"description": "Download status - waiting in queue"}, - "statusDownloading": "Downloading", - "@statusDownloading": {"description": "Download status - in progress"}, - "statusFinalizing": "Finalizing", - "@statusFinalizing": {"description": "Download status - writing metadata"}, - "statusCompleted": "Completed", - "@statusCompleted": {"description": "Download status - finished"}, - "statusFailed": "Failed", - "@statusFailed": {"description": "Download status - error occurred"}, - "statusSkipped": "Skipped", - "@statusSkipped": {"description": "Download status - already exists"}, - "statusPaused": "Paused", - "@statusPaused": {"description": "Download status - paused"}, - "actionPause": "Pause", - "@actionPause": {"description": "Action button - pause download"}, + "@actionPause": { + "description": "Action button - pause download" + }, "actionResume": "Resume", - "@actionResume": {"description": "Action button - resume download"}, + "@actionResume": { + "description": "Action button - resume download" + }, "actionCancel": "Cancel", - "@actionCancel": {"description": "Action button - cancel operation"}, - "actionStop": "Stop", - "@actionStop": {"description": "Action button - stop operation"}, - "actionSelect": "Select", - "@actionSelect": {"description": "Action button - enter selection mode"}, + "@actionCancel": { + "description": "Action button - cancel operation" + }, "actionSelectAll": "Select All", - "@actionSelectAll": {"description": "Action button - select all items"}, + "@actionSelectAll": { + "description": "Action button - select all items" + }, "actionDeselect": "Deselect", - "@actionDeselect": {"description": "Action button - deselect all"}, - "actionPaste": "Paste", - "@actionPaste": {"description": "Action button - paste from clipboard"}, - "actionImportCsv": "Import CSV", - "@actionImportCsv": {"description": "Action button - import CSV file"}, + "@actionDeselect": { + "description": "Action button - deselect all" + }, "actionRemoveCredentials": "Remove Credentials", - "@actionRemoveCredentials": {"description": "Action button - delete Spotify credentials"}, + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, "actionSaveCredentials": "Save Credentials", - "@actionSaveCredentials": {"description": "Action button - save Spotify credentials"}, - + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, "selectionSelected": "{count} selected", "@selectionSelected": { "description": "Selection count indicator", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "selectionAllSelected": "All tracks selected", - "@selectionAllSelected": {"description": "Status - all items selected"}, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": {"description": "Hint - how to select items"}, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": {"type": "int"} - } + "@selectionAllSelected": { + "description": "Status - all items selected" }, "selectionSelectToDelete": "Select tracks to delete", - "@selectionSelectToDelete": {"description": "Placeholder when nothing selected"}, - + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, "progressFetchingMetadata": "Fetching metadata... {current}/{total}", "@progressFetchingMetadata": { "description": "Progress indicator - loading track info", "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "progressReadingCsv": "Reading CSV...", - "@progressReadingCsv": {"description": "Progress indicator - parsing CSV file"}, - - "searchSongs": "Songs", - "@searchSongs": {"description": "Search result category - songs"}, - "searchArtists": "Artists", - "@searchArtists": {"description": "Search result category - artists"}, - "searchAlbums": "Albums", - "@searchAlbums": {"description": "Search result category - albums"}, - "searchPlaylists": "Playlists", - "@searchPlaylists": {"description": "Search result category - playlists"}, - - "tooltipPlay": "Play", - "@tooltipPlay": {"description": "Tooltip - play button"}, - "tooltipCancel": "Cancel", - "@tooltipCancel": {"description": "Tooltip - cancel button"}, - "tooltipStop": "Stop", - "@tooltipStop": {"description": "Tooltip - stop button"}, - "tooltipRetry": "Retry", - "@tooltipRetry": {"description": "Tooltip - retry button"}, - "tooltipRemove": "Remove", - "@tooltipRemove": {"description": "Tooltip - remove button"}, - "tooltipClear": "Clear", - "@tooltipClear": {"description": "Tooltip - clear button"}, - "tooltipPaste": "Paste", - "@tooltipPaste": {"description": "Tooltip - paste button"}, - - "filenameFormat": "Filename Format", - "@filenameFormat": {"description": "Setting title - filename pattern"}, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": {"type": "String"} - } + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Songs", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artists", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Albums", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlists", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Play", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "filenameFormat": "Filename Format", + "@filenameFormat": { + "description": "Setting title - filename pattern" }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": {"description": "Label for placeholder list"}, - "filenameHint": "{artist} - {title}", - "@filenameHint": {"description": "Default filename format hint"}, "filenameShowAdvancedTags": "Show advanced tags", "@filenameShowAdvancedTags": { "description": "Toggle label for showing advanced filename tags" @@ -900,1600 +999,2107 @@ "@filenameShowAdvancedTagsDescription": { "description": "Description for advanced filename tag toggle" }, - - "folderOrganization": "Folder Organization", - "@folderOrganization": {"description": "Setting title - folder structure"}, "folderOrganizationNone": "No organization", - "@folderOrganizationNone": {"description": "Folder option - flat structure"}, + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, "folderOrganizationByArtist": "By Artist", - "@folderOrganizationByArtist": {"description": "Folder option - artist folders"}, + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, "folderOrganizationByAlbum": "By Album", - "@folderOrganizationByAlbum": {"description": "Folder option - album folders"}, + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, "folderOrganizationByArtistAlbum": "Artist/Album", - "@folderOrganizationByArtistAlbum": {"description": "Folder option - nested folders"}, + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, "folderOrganizationDescription": "Organize downloaded files into folders", - "@folderOrganizationDescription": {"description": "Folder organization sheet description"}, + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, "folderOrganizationNoneSubtitle": "All files in download folder", - "@folderOrganizationNoneSubtitle": {"description": "Subtitle for no organization option"}, + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, "folderOrganizationByArtistSubtitle": "Separate folder for each artist", - "@folderOrganizationByArtistSubtitle": {"description": "Subtitle for artist folder option"}, + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, "folderOrganizationByAlbumSubtitle": "Separate folder for each album", - "@folderOrganizationByAlbumSubtitle": {"description": "Subtitle for album folder option"}, + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, "folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album", - "@folderOrganizationByArtistAlbumSubtitle": {"description": "Subtitle for nested folder option"}, - + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, "updateAvailable": "Update Available", - "@updateAvailable": {"description": "Update dialog title"}, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": {"type": "String"} - } + "@updateAvailable": { + "description": "Update dialog title" }, - "updateDownload": "Download", - "@updateDownload": {"description": "Update button - download update"}, "updateLater": "Later", - "@updateLater": {"description": "Update button - dismiss"}, - "updateChangelog": "Changelog", - "@updateChangelog": {"description": "Link to changelog"}, - "updateStartingDownload": "Starting download...", - "@updateStartingDownload": {"description": "Update status - initializing"}, - "updateDownloadFailed": "Download failed", - "@updateDownloadFailed": {"description": "Update error title"}, - "updateFailedMessage": "Failed to download update", - "@updateFailedMessage": {"description": "Update error message"}, - "updateNewVersionReady": "A new version is ready", - "@updateNewVersionReady": {"description": "Update subtitle"}, - "updateCurrent": "Current", - "@updateCurrent": {"description": "Label for current version"}, - "updateNew": "New", - "@updateNew": {"description": "Label for new version"}, - "updateDownloading": "Downloading...", - "@updateDownloading": {"description": "Update status - downloading"}, - "updateWhatsNew": "What's New", - "@updateWhatsNew": {"description": "Changelog section title"}, - "updateDownloadInstall": "Download & Install", - "@updateDownloadInstall": {"description": "Update button - download and install"}, - "updateDontRemind": "Don't remind", - "@updateDontRemind": {"description": "Update button - skip this version"}, - - "providerPriority": "Provider Priority", - "@providerPriority": {"description": "Setting title - download provider order"}, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": {"description": "Subtitle for provider priority"}, - "providerPriorityTitle": "Provider Priority", - "@providerPriorityTitle": {"description": "Provider priority page title"}, - "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", - "@providerPriorityDescription": {"description": "Provider priority page description"}, - "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", - "@providerPriorityInfo": {"description": "Info tip about fallback behavior"}, - "providerBuiltIn": "Built-in", - "@providerBuiltIn": {"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"}, - "providerExtension": "Extension", - "@providerExtension": {"description": "Label for extension-provided providers"}, - - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": {"description": "Setting title - metadata provider order"}, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": {"description": "Subtitle for metadata priority"}, - "metadataProviderPriorityTitle": "Metadata Priority", - "@metadataProviderPriorityTitle": {"description": "Metadata priority page title"}, - "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", - "@metadataProviderPriorityDescription": {"description": "Metadata priority page description"}, - "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", - "@metadataProviderPriorityInfo": {"description": "Info tip about rate limits"}, - "metadataNoRateLimits": "No rate limits", - "@metadataNoRateLimits": {"description": "Deezer provider description"}, - "metadataMayRateLimit": "May rate limit", - "@metadataMayRateLimit": {"description": "Spotify provider description"}, - - "logTitle": "Logs", - "@logTitle": {"description": "Logs screen title"}, - "logCopy": "Copy Logs", - "@logCopy": {"description": "Action - copy logs to clipboard"}, - "logClear": "Clear Logs", - "@logClear": {"description": "Action - delete all logs"}, - "logShare": "Share Logs", - "@logShare": {"description": "Action - share logs file"}, - "logEmpty": "No logs yet", - "@logEmpty": {"description": "Empty state title"}, - "logCopied": "Logs copied to clipboard", - "@logCopied": {"description": "Snackbar - logs copied"}, - "logSearchHint": "Search logs...", - "@logSearchHint": {"description": "Log search placeholder"}, - "logFilterLevel": "Level", - "@logFilterLevel": {"description": "Filter by log level"}, - "logFilterSection": "Filter", - "@logFilterSection": {"description": "Filter section title"}, - "logShareLogs": "Share logs", - "@logShareLogs": {"description": "Share button tooltip"}, - "logClearLogs": "Clear logs", - "@logClearLogs": {"description": "Clear button tooltip"}, - "logClearLogsTitle": "Clear Logs", - "@logClearLogsTitle": {"description": "Clear logs dialog title"}, - "logClearLogsMessage": "Are you sure you want to clear all logs?", - "@logClearLogsMessage": {"description": "Clear logs confirmation message"}, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": {"description": "Error category - ISP blocking"}, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": {"description": "Error category - rate limiting"}, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": {"description": "Error category - network issues"}, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": {"description": "Error category - missing tracks"}, - "logFilterBySeverity": "Filter logs by severity", - "@logFilterBySeverity": {"description": "Filter dialog title"}, - "logNoLogsYet": "No logs yet", - "@logNoLogsYet": {"description": "Empty state title"}, - "logNoLogsYetSubtitle": "Logs will appear here as you use the app", - "@logNoLogsYetSubtitle": {"description": "Empty state subtitle"}, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": {"description": "Section header for error summary"}, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": {"description": "ISP blocking explanation"}, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": {"description": "ISP blocking fix suggestion"}, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": {"description": "Rate limit explanation"}, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": {"description": "Rate limit fix suggestion"}, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": {"description": "Network error explanation"}, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": {"description": "Network error fix suggestion"}, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": {"description": "Track not found explanation"}, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": {"description": "Track not found explanation"}, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": {"type": "int"} - } + "@updateLater": { + "description": "Update button - dismiss" }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": {"type": "String"} - } + "updateStartingDownload": "Starting download...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Download failed", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Failed to download update", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "A new version is ready", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Current", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "New", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Downloading...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "What's New", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Download & Install", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Don't remind", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriorityTitle": "Provider Priority", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Built-in", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Extension", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriorityTitle": "Metadata Priority", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "No rate limits", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "May rate limit", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Logs", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopied": "Logs copied to clipboard", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Search logs...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Level", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filter", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Share logs", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Clear logs", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Clear Logs", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Are you sure you want to clear all logs?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logFilterBySeverity": "Filter logs by severity", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "No logs yet", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "logEntries": "Entries ({count})", "@logEntries": { "description": "Total log count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "credentialsTitle": "Spotify Credentials", - "@credentialsTitle": {"description": "Credentials dialog title"}, + "@credentialsTitle": { + "description": "Credentials dialog title" + }, "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", - "@credentialsDescription": {"description": "Credentials dialog explanation"}, + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, "credentialsClientId": "Client ID", - "@credentialsClientId": {"description": "Client ID field label - DO NOT TRANSLATE"}, + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, "credentialsClientIdHint": "Paste Client ID", - "@credentialsClientIdHint": {"description": "Client ID placeholder"}, + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, "credentialsClientSecret": "Client Secret", - "@credentialsClientSecret": {"description": "Client Secret field label - DO NOT TRANSLATE"}, + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, "credentialsClientSecretHint": "Paste Client Secret", - "@credentialsClientSecretHint": {"description": "Client Secret placeholder"}, - + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, "channelStable": "Stable", - "@channelStable": {"description": "Update channel - stable releases"}, + "@channelStable": { + "description": "Update channel - stable releases" + }, "channelPreview": "Preview", - "@channelPreview": {"description": "Update channel - beta/preview releases"}, - + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, "sectionSearchSource": "Search Source", - "@sectionSearchSource": {"description": "Settings section header"}, + "@sectionSearchSource": { + "description": "Settings section header" + }, "sectionDownload": "Download", - "@sectionDownload": {"description": "Settings section header"}, + "@sectionDownload": { + "description": "Settings section header" + }, "sectionPerformance": "Performance", - "@sectionPerformance": {"description": "Settings section header"}, + "@sectionPerformance": { + "description": "Settings section header" + }, "sectionApp": "App", - "@sectionApp": {"description": "Settings section header"}, + "@sectionApp": { + "description": "Settings section header" + }, "sectionData": "Data", - "@sectionData": {"description": "Settings section header"}, + "@sectionData": { + "description": "Settings section header" + }, "sectionDebug": "Debug", - "@sectionDebug": {"description": "Settings section header"}, + "@sectionDebug": { + "description": "Settings section header" + }, "sectionService": "Service", - "@sectionService": {"description": "Settings section header"}, + "@sectionService": { + "description": "Settings section header" + }, "sectionAudioQuality": "Audio Quality", - "@sectionAudioQuality": {"description": "Settings section header"}, + "@sectionAudioQuality": { + "description": "Settings section header" + }, "sectionFileSettings": "File Settings", - "@sectionFileSettings": {"description": "Settings section header"}, + "@sectionFileSettings": { + "description": "Settings section header" + }, "sectionLyrics": "Lyrics", - "@sectionLyrics": {"description": "Settings section header"}, - + "@sectionLyrics": { + "description": "Settings section header" + }, "lyricsMode": "Lyrics Mode", - "@lyricsMode": {"description": "Setting - how to save lyrics"}, + "@lyricsMode": { + "description": "Setting - how to save lyrics" + }, "lyricsModeDescription": "Choose how lyrics are saved with your downloads", - "@lyricsModeDescription": {"description": "Lyrics mode picker description"}, + "@lyricsModeDescription": { + "description": "Lyrics mode picker description" + }, "lyricsModeEmbed": "Embed in file", - "@lyricsModeEmbed": {"description": "Lyrics mode option - embed in audio file"}, + "@lyricsModeEmbed": { + "description": "Lyrics mode option - embed in audio file" + }, "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", - "@lyricsModeEmbedSubtitle": {"description": "Subtitle for embed option"}, + "@lyricsModeEmbedSubtitle": { + "description": "Subtitle for embed option" + }, "lyricsModeExternal": "External .lrc file", - "@lyricsModeExternal": {"description": "Lyrics mode option - separate LRC file"}, + "@lyricsModeExternal": { + "description": "Lyrics mode option - separate LRC file" + }, "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", - "@lyricsModeExternalSubtitle": {"description": "Subtitle for external option"}, + "@lyricsModeExternalSubtitle": { + "description": "Subtitle for external option" + }, "lyricsModeBoth": "Both", - "@lyricsModeBoth": {"description": "Lyrics mode option - embed and external"}, + "@lyricsModeBoth": { + "description": "Lyrics mode option - embed and external" + }, "lyricsModeBothSubtitle": "Embed and save .lrc file", - "@lyricsModeBothSubtitle": {"description": "Subtitle for both option"}, - + "@lyricsModeBothSubtitle": { + "description": "Subtitle for both option" + }, "sectionColor": "Color", - "@sectionColor": {"description": "Settings section header"}, + "@sectionColor": { + "description": "Settings section header" + }, "sectionTheme": "Theme", - "@sectionTheme": {"description": "Settings section header"}, + "@sectionTheme": { + "description": "Settings section header" + }, "sectionLayout": "Layout", -"@sectionLayout": {"description": "Settings section header"}, + "@sectionLayout": { + "description": "Settings section header" + }, "sectionLanguage": "Language", - "@sectionLanguage": {"description": "Settings section header for language"}, + "@sectionLanguage": { + "description": "Settings section header for language" + }, "appearanceLanguage": "App Language", - "@appearanceLanguage": {"description": "Language setting title"}, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": {"description": "Language setting subtitle"}, - + "@appearanceLanguage": { + "description": "Language setting title" + }, "settingsAppearanceSubtitle": "Theme, colors, display", - "@settingsAppearanceSubtitle": {"description": "Appearance settings description"}, + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, "settingsDownloadSubtitle": "Service, quality, filename format", - "@settingsDownloadSubtitle": {"description": "Download settings description"}, + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", - "@settingsOptionsSubtitle": {"description": "Options settings description"}, + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, "settingsExtensionsSubtitle": "Manage download providers", - "@settingsExtensionsSubtitle": {"description": "Extensions settings description"}, + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, "settingsLogsSubtitle": "View app logs for debugging", - "@settingsLogsSubtitle": {"description": "Logs settings description"}, - + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, "loadingSharedLink": "Loading shared link...", - "@loadingSharedLink": {"description": "Status when opening shared URL"}, + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, "pressBackAgainToExit": "Press back again to exit", - "@pressBackAgainToExit": {"description": "Exit confirmation message"}, - - "tracksHeader": "Tracks", - "@tracksHeader": {"description": "Section header for track list"}, + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", "placeholders": { - "count": {"type": "int"} - } - }, - "playAllCount": "Play All ({count})", - "@playAllCount": { - "description": "Play all button with count", - "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", "@tracksCount": { "description": "Track count display", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "trackCopyFilePath": "Copy file path", - "@trackCopyFilePath": {"description": "Action - copy file path"}, - "trackRemoveFromDevice": "Remove from device", - "@trackRemoveFromDevice": {"description": "Action - delete downloaded file"}, - "trackLoadLyrics": "Load Lyrics", - "@trackLoadLyrics": {"description": "Action - fetch lyrics"}, - "trackMetadata": "Metadata", - "@trackMetadata": {"description": "Tab title - track metadata"}, - "trackFileInfo": "File Info", - "@trackFileInfo": {"description": "Tab title - file information"}, - "trackLyrics": "Lyrics", - "@trackLyrics": {"description": "Tab title - lyrics"}, - "trackFileNotFound": "File not found", - "@trackFileNotFound": {"description": "Error - file doesn't exist"}, - "trackOpenInDeezer": "Open in Deezer", - "@trackOpenInDeezer": {"description": "Action - open track in Deezer app"}, - "trackOpenInSpotify": "Open in Spotify", - "@trackOpenInSpotify": {"description": "Action - open track in Spotify app"}, - "trackTrackName": "Track name", - "@trackTrackName": {"description": "Metadata label - track title"}, - "trackArtist": "Artist", - "@trackArtist": {"description": "Metadata label - artist name"}, - "trackAlbumArtist": "Album artist", - "@trackAlbumArtist": {"description": "Metadata label - album artist"}, - "trackAlbum": "Album", - "@trackAlbum": {"description": "Metadata label - album name"}, - "trackTrackNumber": "Track number", - "@trackTrackNumber": {"description": "Metadata label - track number"}, - "trackDiscNumber": "Disc number", - "@trackDiscNumber": {"description": "Metadata label - disc number"}, - "trackDuration": "Duration", - "@trackDuration": {"description": "Metadata label - track length"}, - "trackAudioQuality": "Audio quality", - "@trackAudioQuality": {"description": "Metadata label - audio quality"}, - "trackReleaseDate": "Release date", - "@trackReleaseDate": {"description": "Metadata label - release date"}, - "trackGenre": "Genre", - "@trackGenre": {"description": "Metadata label - music genre"}, - "trackLabel": "Label", - "@trackLabel": {"description": "Metadata label - record label"}, - "trackCopyright": "Copyright", - "@trackCopyright": {"description": "Metadata label - copyright information"}, - "trackDownloaded": "Downloaded", - "@trackDownloaded": {"description": "Metadata label - download date"}, - "trackCopyLyrics": "Copy lyrics", - "@trackCopyLyrics": {"description": "Action - copy lyrics to clipboard"}, - "trackLyricsNotAvailable": "Lyrics not available for this track", - "@trackLyricsNotAvailable": {"description": "Message when lyrics not found"}, - "trackLyricsTimeout": "Request timed out. Try again later.", - "@trackLyricsTimeout": {"description": "Message when lyrics request times out"}, - "trackLyricsLoadFailed": "Failed to load lyrics", - "@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"}, - "trackEmbedLyrics": "Embed Lyrics", - "@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"}, - "trackLyricsEmbedded": "Lyrics embedded successfully", - "@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"}, - "trackInstrumental": "Instrumental track", - "@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"}, - "trackCopiedToClipboard": "Copied to clipboard", - "@trackCopiedToClipboard": {"description": "Snackbar - content copied"}, - "trackDeleteConfirmTitle": "Remove from device?", - "@trackDeleteConfirmTitle": {"description": "Delete confirmation title"}, - "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", - "@trackDeleteConfirmMessage": {"description": "Delete confirmation message"}, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": {"type": "String"} - } + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Remove from device", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Load Lyrics", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadata", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "File Info", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Lyrics", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "File not found", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Open in Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Open in Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Track name", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artist", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Album artist", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Album", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Track number", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Disc number", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Duration", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Audio quality", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Release date", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackGenre": "Genre", + "@trackGenre": { + "description": "Metadata label - music genre" + }, + "trackLabel": "Label", + "@trackLabel": { + "description": "Metadata label - record label" + }, + "trackCopyright": "Copyright", + "@trackCopyright": { + "description": "Metadata label - copyright information" + }, + "trackDownloaded": "Downloaded", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Copy lyrics", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lyrics not available for this track", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Request timed out. Try again later.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Failed to load lyrics", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackEmbedLyrics": "Embed Lyrics", + "@trackEmbedLyrics": { + "description": "Action - embed lyrics into audio file" + }, + "trackLyricsEmbedded": "Lyrics embedded successfully", + "@trackLyricsEmbedded": { + "description": "Snackbar - lyrics saved to file" + }, + "trackInstrumental": "Instrumental track", + "@trackInstrumental": { + "description": "Message when track is instrumental (no lyrics)" + }, + "trackCopiedToClipboard": "Copied to clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Remove from device?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" }, - "dateToday": "Today", - "@dateToday": {"description": "Relative date - today"}, + "@dateToday": { + "description": "Relative date - today" + }, "dateYesterday": "Yesterday", - "@dateYesterday": {"description": "Relative date - yesterday"}, + "@dateYesterday": { + "description": "Relative date - yesterday" + }, "dateDaysAgo": "{count} days ago", "@dateDaysAgo": { "description": "Relative date - days ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "dateWeeksAgo": "{count} weeks ago", "@dateWeeksAgo": { "description": "Relative date - weeks ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "dateMonthsAgo": "{count} months ago", "@dateMonthsAgo": { "description": "Relative date - months ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - - "concurrentSequential": "Sequential", - "@concurrentSequential": {"description": "Download mode - one at a time"}, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": {"description": "Download mode - 2 simultaneous"}, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": {"description": "Download mode - 3 simultaneous"}, - - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": {"description": "Tooltip for failed download"}, - "storeFilterAll": "All", - "@storeFilterAll": {"description": "Store filter - all extensions"}, + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, "storeFilterMetadata": "Metadata", - "@storeFilterMetadata": {"description": "Store filter - metadata providers"}, + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, "storeFilterDownload": "Download", - "@storeFilterDownload": {"description": "Store filter - download providers"}, + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, "storeFilterUtility": "Utility", - "@storeFilterUtility": {"description": "Store filter - utility extensions"}, + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, "storeFilterLyrics": "Lyrics", - "@storeFilterLyrics": {"description": "Store filter - lyrics providers"}, + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, "storeFilterIntegration": "Integration", - "@storeFilterIntegration": {"description": "Store filter - integrations"}, + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, "storeClearFilters": "Clear filters", - "@storeClearFilters": {"description": "Button to clear all filters"}, - "storeNoResults": "No extensions found", - "@storeNoResults": {"description": "Empty state when no extensions match filters"}, - - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": {"description": "Extension capability - provider priority"}, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": {"description": "Button to install extension"}, + "@storeClearFilters": { + "description": "Button to clear all filters" + }, "extensionDefaultProvider": "Default (Deezer/Spotify)", - "@extensionDefaultProvider": {"description": "Default search provider option"}, + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, "extensionDefaultProviderSubtitle": "Use built-in search", - "@extensionDefaultProviderSubtitle": {"description": "Subtitle for default provider"}, + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, "extensionAuthor": "Author", - "@extensionAuthor": {"description": "Extension detail - author"}, + "@extensionAuthor": { + "description": "Extension detail - author" + }, "extensionId": "ID", - "@extensionId": {"description": "Extension detail - unique ID"}, + "@extensionId": { + "description": "Extension detail - unique ID" + }, "extensionError": "Error", - "@extensionError": {"description": "Extension detail - error message"}, + "@extensionError": { + "description": "Extension detail - error message" + }, "extensionCapabilities": "Capabilities", - "@extensionCapabilities": {"description": "Section header - extension features"}, + "@extensionCapabilities": { + "description": "Section header - extension features" + }, "extensionMetadataProvider": "Metadata Provider", - "@extensionMetadataProvider": {"description": "Capability - provides metadata"}, + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, "extensionDownloadProvider": "Download Provider", - "@extensionDownloadProvider": {"description": "Capability - provides downloads"}, + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, "extensionLyricsProvider": "Lyrics Provider", - "@extensionLyricsProvider": {"description": "Capability - provides lyrics"}, + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, "extensionUrlHandler": "URL Handler", - "@extensionUrlHandler": {"description": "Capability - handles URLs"}, + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, "extensionQualityOptions": "Quality Options", - "@extensionQualityOptions": {"description": "Capability - quality selection"}, + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, "extensionPostProcessingHooks": "Post-Processing Hooks", - "@extensionPostProcessingHooks": {"description": "Capability - post-processing"}, + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, "extensionPermissions": "Permissions", - "@extensionPermissions": {"description": "Section header - required permissions"}, + "@extensionPermissions": { + "description": "Section header - required permissions" + }, "extensionSettings": "Settings", - "@extensionSettings": {"description": "Section header - extension settings"}, + "@extensionSettings": { + "description": "Section header - extension settings" + }, "extensionRemoveButton": "Remove Extension", - "@extensionRemoveButton": {"description": "Button to uninstall extension"}, + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, "extensionUpdated": "Updated", - "@extensionUpdated": {"description": "Extension detail - last update"}, + "@extensionUpdated": { + "description": "Extension detail - last update" + }, "extensionMinAppVersion": "Min App Version", - "@extensionMinAppVersion": {"description": "Extension detail - minimum app version"}, + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, "extensionCustomTrackMatching": "Custom Track Matching", - "@extensionCustomTrackMatching": {"description": "Capability - custom track matching algorithm"}, + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, "extensionPostProcessing": "Post-Processing", - "@extensionPostProcessing": {"description": "Capability - post-download processing"}, + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, "extensionHooksAvailable": "{count} hook(s) available", "@extensionHooksAvailable": { "description": "Post-processing hooks count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "extensionPatternsCount": "{count} pattern(s)", "@extensionPatternsCount": { "description": "URL patterns count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "extensionStrategy": "Strategy: {strategy}", "@extensionStrategy": { "description": "Track matching strategy name", "placeholders": { - "strategy": {"type": "String"} + "strategy": { + "type": "String" + } } }, "extensionsProviderPrioritySection": "Provider Priority", - "@extensionsProviderPrioritySection": {"description": "Section header - provider priority"}, + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, "extensionsInstalledSection": "Installed Extensions", - "@extensionsInstalledSection": {"description": "Section header - installed extensions"}, + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, "extensionsNoExtensions": "No extensions installed", - "@extensionsNoExtensions": {"description": "Empty state - no extensions"}, + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", - "@extensionsNoExtensionsSubtitle": {"description": "Empty state subtitle"}, + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, "extensionsInstallButton": "Install Extension", - "@extensionsInstallButton": {"description": "Button to install extension from file"}, + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", - "@extensionsInfoTip": {"description": "Security warning about extensions"}, + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, "extensionsInstalledSuccess": "Extension installed successfully", - "@extensionsInstalledSuccess": {"description": "Success message after install"}, + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, "extensionsDownloadPriority": "Download Priority", - "@extensionsDownloadPriority": {"description": "Setting - download provider order"}, + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, "extensionsDownloadPrioritySubtitle": "Set download service order", - "@extensionsDownloadPrioritySubtitle": {"description": "Subtitle for download priority"}, + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, "extensionsNoDownloadProvider": "No extensions with download provider", - "@extensionsNoDownloadProvider": {"description": "Empty state - no download providers"}, + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, "extensionsMetadataPriority": "Metadata Priority", - "@extensionsMetadataPriority": {"description": "Setting - metadata provider order"}, + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", - "@extensionsMetadataPrioritySubtitle": {"description": "Subtitle for metadata priority"}, + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, "extensionsNoMetadataProvider": "No extensions with metadata provider", - "@extensionsNoMetadataProvider": {"description": "Empty state - no metadata providers"}, + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, "extensionsSearchProvider": "Search Provider", - "@extensionsSearchProvider": {"description": "Setting - search provider selection"}, + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, "extensionsNoCustomSearch": "No extensions with custom search", - "@extensionsNoCustomSearch": {"description": "Empty state - no search providers"}, + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", - "@extensionsSearchProviderDescription": {"description": "Search provider setting description"}, + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, "extensionsCustomSearch": "Custom search", - "@extensionsCustomSearch": {"description": "Label for custom search provider"}, + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, "extensionsErrorLoading": "Error loading extension", - "@extensionsErrorLoading": {"description": "Error message when extension fails to load"}, - + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, "qualityFlacLossless": "FLAC Lossless", - "@qualityFlacLossless": {"description": "Quality option - CD quality FLAC"}, + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", - "@qualityFlacLosslessSubtitle": {"description": "Technical spec for lossless"}, + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, "qualityHiResFlac": "Hi-Res FLAC", - "@qualityHiResFlac": {"description": "Quality option - high resolution FLAC"}, + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", - "@qualityHiResFlacSubtitle": {"description": "Technical spec for hi-res"}, + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, "qualityHiResFlacMax": "Hi-Res FLAC Max", - "@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"}, + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", - "@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"}, - "qualityLossy": "Lossy", - "@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"}, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"}, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"}, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": {"description": "Setting - enable lossy quality option"}, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": {"description": "Subtitle when lossy is enabled"}, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": {"description": "Subtitle when lossy is disabled"}, - "lossyFormat": "Lossy Format", - "@lossyFormat": {"description": "Setting - choose lossy format"}, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": {"description": "Description for lossy format picker"}, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": {"description": "MP3 format description"}, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": {"description": "Opus format description"}, + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, "qualityNote": "Actual quality depends on track availability from the service", - "@qualityNote": {"description": "Note about quality availability"}, + "@qualityNote": { + "description": "Note about quality availability" + }, "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"}, + "@youtubeQualityNote": { + "description": "Note for YouTube service explaining lossy-only quality" + }, "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": {"description": "Title for YouTube Opus bitrate setting"}, + "@youtubeOpusBitrateTitle": { + "description": "Title for YouTube Opus bitrate setting" + }, "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": {"description": "Title for YouTube MP3 bitrate setting"}, - "youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})", - "@youtubeBitrateSubtitle": { - "description": "Subtitle showing current bitrate and valid range", - "placeholders": { - "bitrate": {"type": "int"}, - "min": {"type": "int"}, - "max": {"type": "int"} - } + "@youtubeMp3BitrateTitle": { + "description": "Title for YouTube MP3 bitrate setting" }, - "youtubeBitrateInputHelp": "Enter custom bitrate ({min}-{max} kbps)", - "@youtubeBitrateInputHelp": { - "description": "Helper text for manual YouTube bitrate input", - "placeholders": { - "min": {"type": "int"}, - "max": {"type": "int"} - } - }, - "youtubeBitrateFieldLabel": "Bitrate (kbps)", - "@youtubeBitrateFieldLabel": {"description": "Label for YouTube bitrate input field"}, - "youtubeBitrateValidationError": "Bitrate must be between {min} and {max} kbps", - "@youtubeBitrateValidationError": { - "description": "Validation error for invalid YouTube bitrate input", - "placeholders": { - "min": {"type": "int"}, - "max": {"type": "int"} - } - }, - "downloadAskBeforeDownload": "Ask Before Download", - "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, "downloadDirectory": "Download Directory", - "@downloadDirectory": {"description": "Setting - download folder"}, + "@downloadDirectory": { + "description": "Setting - download folder" + }, "downloadSeparateSinglesFolder": "Separate Singles Folder", - "@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"}, + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, "downloadAlbumFolderStructure": "Album Folder Structure", - "@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"}, + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, "downloadUseAlbumArtistForFolders": "Use Album Artist for folders", - "@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"}, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"}, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"}, + "@downloadUseAlbumArtistForFolders": { + "description": "Setting - choose whether artist folders use Album Artist or Track Artist" + }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", - "@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"}, + "@downloadUsePrimaryArtistOnly": { + "description": "Setting - strip featured artists from folder name" + }, "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", - "@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"}, + "@downloadUsePrimaryArtistOnlyEnabled": { + "description": "Subtitle when primary artist only is enabled" + }, "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", - "@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"}, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": {"description": "Setting - output file format"}, - "downloadSelectService": "Select Service", - "@downloadSelectService": {"description": "Dialog title - choose download service"}, + "@downloadUsePrimaryArtistOnlyDisabled": { + "description": "Subtitle when primary artist only is disabled" + }, "downloadSelectQuality": "Select Quality", - "@downloadSelectQuality": {"description": "Dialog title - choose audio quality"}, + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, "downloadFrom": "Download From", - "@downloadFrom": {"description": "Label - download source"}, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": {"description": "Label - default quality setting"}, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": {"description": "Quality option - highest available"}, - - "folderNone": "None", - "@folderNone": {"description": "Folder option - no organization"}, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": {"description": "Subtitle for no folder organization"}, - "folderArtist": "Artist", - "@folderArtist": {"description": "Folder option - by artist"}, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": {"description": "Folder structure example"}, - "folderAlbum": "Album", - "@folderAlbum": {"description": "Folder option - by album"}, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": {"description": "Folder structure example"}, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": {"description": "Folder option - nested"}, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": {"description": "Folder structure example"}, - - "serviceTidal": "Tidal", - "@serviceTidal": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceQobuz": "Qobuz", - "@serviceQobuz": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceAmazon": "Amazon", - "@serviceAmazon": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceDeezer": "Deezer", - "@serviceDeezer": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceSpotify": "Spotify", - "@serviceSpotify": {"description": "Service name - DO NOT TRANSLATE"}, - + "@downloadFrom": { + "description": "Label - download source" + }, "appearanceAmoledDark": "AMOLED Dark", - "@appearanceAmoledDark": {"description": "Theme option - pure black"}, + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, "appearanceAmoledDarkSubtitle": "Pure black background", - "@appearanceAmoledDarkSubtitle": {"description": "Subtitle for AMOLED dark"}, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": {"description": "Color picker dialog title"}, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": {"description": "Theme picker dialog title"}, - - "queueTitle": "Download Queue", - "@queueTitle": {"description": "Queue screen title"}, -"queueClearAll": "Clear All", - "@queueClearAll": {"description": "Button - clear all queue items"}, + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "queueClearAll": "Clear All", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, "queueClearAllMessage": "Are you sure you want to clear all downloads?", - "@queueClearAllMessage": {"description": "Clear queue confirmation"}, - "queueExportFailed": "Export", - "@queueExportFailed": {"description": "Button - export failed downloads to TXT"}, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"}, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": {"description": "Action to clear failed downloads after export"}, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": {"description": "Error message when export fails"}, + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, "settingsAutoExportFailed": "Auto-export failed downloads", - "@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"}, + "@settingsAutoExportFailed": { + "description": "Setting toggle for auto-export" + }, "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", - "@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"}, - + "@settingsAutoExportFailedSubtitle": { + "description": "Subtitle for auto-export setting" + }, "settingsDownloadNetwork": "Download Network", - "@settingsDownloadNetwork": {"description": "Setting for network type preference"}, + "@settingsDownloadNetwork": { + "description": "Setting for network type preference" + }, "settingsDownloadNetworkAny": "WiFi + Mobile Data", - "@settingsDownloadNetworkAny": {"description": "Network option - use any connection"}, + "@settingsDownloadNetworkAny": { + "description": "Network option - use any connection" + }, "settingsDownloadNetworkWifiOnly": "WiFi Only", - "@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"}, + "@settingsDownloadNetworkWifiOnly": { + "description": "Network option - only use WiFi" + }, "settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", - "@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"}, - - "queueEmpty": "No downloads in queue", - "@queueEmpty": {"description": "Empty queue state title"}, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": {"description": "Empty queue state subtitle"}, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": {"description": "Button - clear finished downloads"}, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": {"description": "Error dialog title"}, - "queueTrackLabel": "Track:", - "@queueTrackLabel": {"description": "Label in error dialog"}, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": {"description": "Label in error dialog"}, - "queueErrorLabel": "Error:", - "@queueErrorLabel": {"description": "Label in error dialog"}, - "queueUnknownError": "Unknown error", - "@queueUnknownError": {"description": "Fallback error message"}, - + "@settingsDownloadNetworkSubtitle": { + "description": "Subtitle explaining network preference" + }, "albumFolderArtistAlbum": "Artist / Album", - "@albumFolderArtistAlbum": {"description": "Album folder option"}, + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, "albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", - "@albumFolderArtistAlbumSubtitle": {"description": "Folder structure example"}, + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, "albumFolderArtistYearAlbum": "Artist / [Year] Album", - "@albumFolderArtistYearAlbum": {"description": "Album folder option with year"}, + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", - "@albumFolderArtistYearAlbumSubtitle": {"description": "Folder structure example"}, + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, "albumFolderAlbumOnly": "Album Only", - "@albumFolderAlbumOnly": {"description": "Album folder option"}, + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", - "@albumFolderAlbumOnlySubtitle": {"description": "Folder structure example"}, + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, "albumFolderYearAlbum": "[Year] Album", - "@albumFolderYearAlbum": {"description": "Album folder option with year"}, + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", - "@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"}, + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, "albumFolderArtistAlbumSingles": "Artist / Album + Singles", - "@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"}, + "@albumFolderArtistAlbumSingles": { + "description": "Album folder option with singles inside artist" + }, "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", - "@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"}, - + "@albumFolderArtistAlbumSinglesSubtitle": { + "description": "Folder structure example" + }, "downloadedAlbumDeleteSelected": "Delete Selected", - "@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"}, + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, "downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { - "count": {"type": "int"} - } - }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": {"description": "Section header for tracks"}, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "downloadedAlbumAllSelected": "All tracks selected", - "@downloadedAlbumAllSelected": {"description": "Status - all items selected"}, + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, "downloadedAlbumTapToSelect": "Tap tracks to select", - "@downloadedAlbumTapToSelect": {"description": "Selection hint"}, + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "downloadedAlbumSelectToDelete": "Select tracks to delete", - "@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"}, + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, "downloadedAlbumDiscHeader": "Disc {discNumber}", "@downloadedAlbumDiscHeader": { "description": "Header for disc separator in multi-disc albums", "placeholders": { - "discNumber": {"type": "int", "example": "1"} + "discNumber": { + "type": "int", + "example": "1" + } } }, - - "utilityFunctions": "Utility Functions", - "@utilityFunctions": {"description": "Extension capability - utility functions"}, - "recentTypeArtist": "Artist", - "@recentTypeArtist": {"description": "Recent access item type - artist"}, + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, "recentTypeAlbum": "Album", - "@recentTypeAlbum": {"description": "Recent access item type - album"}, + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, "recentTypeSong": "Song", - "@recentTypeSong": {"description": "Recent access item type - song/track"}, + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, "recentTypePlaylist": "Playlist", - "@recentTypePlaylist": {"description": "Recent access item type - playlist"}, + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, "recentEmpty": "No recent items yet", - "@recentEmpty": {"description": "Empty state text for recent access list"}, + "@recentEmpty": { + "description": "Empty state text for recent access list" + }, "recentShowAllDownloads": "Show All Downloads", "@recentShowAllDownloads": { "description": "Button label to unhide hidden downloads in recent access" }, - "recentPlaylistInfo": "Playlist: {name}", "@recentPlaylistInfo": { "description": "Snackbar message when tapping playlist in recent access", "placeholders": { - "name": {"type": "String", "description": "Playlist name"} + "name": { + "type": "String", + "description": "Playlist name" + } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": {"type": "String", "description": "Error message"} - } - }, - "discographyDownload": "Download Discography", - "@discographyDownload": {"description": "Button - download artist discography"}, - "discographyPlay": "Play Discography", - "@discographyPlay": {"description": "Button - play artist discography"}, + "@discographyDownload": { + "description": "Button - download artist discography" + }, "discographyDownloadAll": "Download All", - "@discographyDownloadAll": {"description": "Option - download entire discography"}, - "discographyPlayAll": "Play All", - "@discographyPlayAll": {"description": "Option - play entire discography"}, + "@discographyDownloadAll": { + "description": "Option - download entire discography" + }, "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", "placeholders": { - "count": {"type": "int"}, - "albumCount": {"type": "int"} + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } } }, "discographyAlbumsOnly": "Albums Only", - "@discographyAlbumsOnly": {"description": "Option - download only albums"}, + "@discographyAlbumsOnly": { + "description": "Option - download only albums" + }, "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", "@discographyAlbumsOnlySubtitle": { "description": "Subtitle showing album tracks count", "placeholders": { - "count": {"type": "int"}, - "albumCount": {"type": "int"} + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } } }, "discographySinglesOnly": "Singles & EPs Only", - "@discographySinglesOnly": {"description": "Option - download only singles"}, + "@discographySinglesOnly": { + "description": "Option - download only singles" + }, "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", "@discographySinglesOnlySubtitle": { "description": "Subtitle showing singles tracks count", "placeholders": { - "count": {"type": "int"}, - "albumCount": {"type": "int"} + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } } }, "discographySelectAlbums": "Select Albums...", - "@discographySelectAlbums": {"description": "Option - manually select albums to download"}, + "@discographySelectAlbums": { + "description": "Option - manually select albums to download" + }, "discographySelectAlbumsSubtitle": "Choose specific albums or singles", - "@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"}, + "@discographySelectAlbumsSubtitle": { + "description": "Subtitle for select albums option" + }, "discographyFetchingTracks": "Fetching tracks...", - "@discographyFetchingTracks": {"description": "Progress - fetching album tracks"}, + "@discographyFetchingTracks": { + "description": "Progress - fetching album tracks" + }, "discographyFetchingAlbum": "Fetching {current} of {total}...", "@discographyFetchingAlbum": { "description": "Progress - fetching specific album", "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "discographySelectedCount": "{count} selected", "@discographySelectedCount": { "description": "Selection count badge", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "discographyDownloadSelected": "Download Selected", - "@discographyDownloadSelected": {"description": "Button - download selected albums"}, - "discographyPlaySelected": "Play Selected", - "@discographyPlaySelected": {"description": "Button - play selected albums"}, + "@discographyDownloadSelected": { + "description": "Button - download selected albums" + }, "discographyAddedToQueue": "Added {count} tracks to queue", "@discographyAddedToQueue": { "description": "Snackbar - tracks added from discography", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", "@discographySkippedDownloaded": { "description": "Snackbar - with skipped tracks count", "placeholders": { - "added": {"type": "int"}, - "skipped": {"type": "int"} + "added": { + "type": "int" + }, + "skipped": { + "type": "int" + } } }, "discographyNoAlbums": "No albums available", - "@discographyNoAlbums": {"description": "Error - no albums found for artist"}, + "@discographyNoAlbums": { + "description": "Error - no albums found for artist" + }, "discographyFailedToFetch": "Failed to fetch some albums", - "@discographyFailedToFetch": {"description": "Error - some albums failed to load"}, - + "@discographyFailedToFetch": { + "description": "Error - some albums failed to load" + }, "sectionStorageAccess": "Storage Access", - "@sectionStorageAccess": {"description": "Section header for storage access settings"}, + "@sectionStorageAccess": { + "description": "Section header for storage access settings" + }, "allFilesAccess": "All Files Access", - "@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"}, + "@allFilesAccess": { + "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" + }, "allFilesAccessEnabledSubtitle": "Can write to any folder", - "@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"}, + "@allFilesAccessEnabledSubtitle": { + "description": "Subtitle when all files access is enabled" + }, "allFilesAccessDisabledSubtitle": "Limited to media folders only", - "@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"}, + "@allFilesAccessDisabledSubtitle": { + "description": "Subtitle when all files access is disabled" + }, "allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.", - "@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"}, + "@allFilesAccessDescription": { + "description": "Description explaining when to enable all files access" + }, "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", - "@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"}, + "@allFilesAccessDeniedMessage": { + "description": "Message when permission is permanently denied" + }, "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", - "@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}, - + "@allFilesAccessDisabledMessage": { + "description": "Snackbar message when user disables all files access" + }, "settingsLocalLibrary": "Local Library", - "@settingsLocalLibrary": {"description": "Settings menu item - local library"}, + "@settingsLocalLibrary": { + "description": "Settings menu item - local library" + }, "settingsLocalLibrarySubtitle": "Scan music & detect duplicates", - "@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"}, + "@settingsLocalLibrarySubtitle": { + "description": "Subtitle for local library settings" + }, "settingsCache": "Storage & Cache", - "@settingsCache": {"description": "Settings menu item - cache management"}, + "@settingsCache": { + "description": "Settings menu item - cache management" + }, "settingsCacheSubtitle": "View size and clear cached data", - "@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"}, + "@settingsCacheSubtitle": { + "description": "Subtitle for cache management menu" + }, "libraryTitle": "Local Library", - "@libraryTitle": {"description": "Library settings page title"}, - "libraryStatus": "Library Status", - "@libraryStatus": {"description": "Section header for library status"}, + "@libraryTitle": { + "description": "Library settings page title" + }, "libraryScanSettings": "Scan Settings", - "@libraryScanSettings": {"description": "Section header for scan settings"}, + "@libraryScanSettings": { + "description": "Section header for scan settings" + }, "libraryEnableLocalLibrary": "Enable Local Library", - "@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"}, + "@libraryEnableLocalLibrary": { + "description": "Toggle to enable library scanning" + }, "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", - "@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"}, + "@libraryEnableLocalLibrarySubtitle": { + "description": "Subtitle for enable toggle" + }, "libraryFolder": "Library Folder", - "@libraryFolder": {"description": "Folder selection setting"}, + "@libraryFolder": { + "description": "Folder selection setting" + }, "libraryFolderHint": "Tap to select folder", - "@libraryFolderHint": {"description": "Placeholder when no folder selected"}, + "@libraryFolderHint": { + "description": "Placeholder when no folder selected" + }, "libraryShowDuplicateIndicator": "Show Duplicate Indicator", - "@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"}, + "@libraryShowDuplicateIndicator": { + "description": "Toggle for duplicate indicator in search" + }, "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", - "@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"}, + "@libraryShowDuplicateIndicatorSubtitle": { + "description": "Subtitle for duplicate indicator toggle" + }, "libraryActions": "Actions", - "@libraryActions": {"description": "Section header for library actions"}, + "@libraryActions": { + "description": "Section header for library actions" + }, "libraryScan": "Scan Library", - "@libraryScan": {"description": "Button to start library scan"}, + "@libraryScan": { + "description": "Button to start library scan" + }, "libraryScanSubtitle": "Scan for audio files", - "@libraryScanSubtitle": {"description": "Subtitle for scan button"}, + "@libraryScanSubtitle": { + "description": "Subtitle for scan button" + }, "libraryScanSelectFolderFirst": "Select a folder first", - "@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"}, + "@libraryScanSelectFolderFirst": { + "description": "Message when trying to scan without folder" + }, "libraryCleanupMissingFiles": "Cleanup Missing Files", - "@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"}, + "@libraryCleanupMissingFiles": { + "description": "Button to remove entries for missing files" + }, "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", - "@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"}, + "@libraryCleanupMissingFilesSubtitle": { + "description": "Subtitle for cleanup button" + }, "libraryClear": "Clear Library", - "@libraryClear": {"description": "Button to clear all library entries"}, + "@libraryClear": { + "description": "Button to clear all library entries" + }, "libraryClearSubtitle": "Remove all scanned tracks", - "@libraryClearSubtitle": {"description": "Subtitle for clear button"}, + "@libraryClearSubtitle": { + "description": "Subtitle for clear button" + }, "libraryClearConfirmTitle": "Clear Library", - "@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"}, + "@libraryClearConfirmTitle": { + "description": "Dialog title for clear confirmation" + }, "libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.", - "@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"}, + "@libraryClearConfirmMessage": { + "description": "Dialog message for clear confirmation" + }, "libraryAbout": "About Local Library", - "@libraryAbout": {"description": "Section header for about info"}, + "@libraryAbout": { + "description": "Section header for about info" + }, "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", - "@libraryAboutDescription": {"description": "Description of local library feature"}, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": {"type": "int"} - } + "@libraryAboutDescription": { + "description": "Description of local library feature" }, "libraryTracksUnit": "{count, plural, =1{track} other{tracks}}", "@libraryTracksUnit": { "description": "Unit label for tracks count (without the number itself)", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", "placeholders": { - "time": {"type": "String"} + "time": { + "type": "String" + } } }, "libraryLastScannedNever": "Never", - "@libraryLastScannedNever": {"description": "Shown when library has never been scanned"}, + "@libraryLastScannedNever": { + "description": "Shown when library has never been scanned" + }, "libraryScanning": "Scanning...", - "@libraryScanning": {"description": "Status during scan"}, + "@libraryScanning": { + "description": "Status during scan" + }, "libraryScanProgress": "{progress}% of {total} files", "@libraryScanProgress": { "description": "Scan progress display", "placeholders": { - "progress": {"type": "String"}, - "total": {"type": "int"} + "progress": { + "type": "String" + }, + "total": { + "type": "int" + } } }, "libraryInLibrary": "In Library", - "@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"}, + "@libraryInLibrary": { + "description": "Badge shown on tracks that exist in local library" + }, "libraryRemovedMissingFiles": "Removed {count} missing files from library", "@libraryRemovedMissingFiles": { "description": "Snackbar after cleanup", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "libraryCleared": "Library cleared", - "@libraryCleared": {"description": "Snackbar after clearing library"}, - "libraryStorageAccessRequired": "Storage Access Required", - "@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"}, - "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", - "@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"}, - "libraryFolderNotExist": "Selected folder does not exist", - "@libraryFolderNotExist": {"description": "Error when folder doesn't exist"}, - "librarySourceDownloaded": "Downloaded", - "@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"}, - "librarySourceLocal": "Local", - "@librarySourceLocal": {"description": "Badge for tracks from local library scan"}, - "libraryFilterAll": "All", - "@libraryFilterAll": {"description": "Filter chip - show all library items"}, - "libraryFilterDownloaded": "Downloaded", - "@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"}, - "libraryFilterLocal": "Local", - "@libraryFilterLocal": {"description": "Filter chip - show only local library items"}, - - "libraryFilterTitle": "Filters", - "@libraryFilterTitle": {"description": "Filter bottom sheet title"}, - "libraryFilterReset": "Reset", - "@libraryFilterReset": {"description": "Reset all filters button"}, - "libraryFilterApply": "Apply", - "@libraryFilterApply": {"description": "Apply filters button"}, - "libraryFilterSource": "Source", - "@libraryFilterSource": {"description": "Filter section - source type"}, - "libraryFilterQuality": "Quality", - "@libraryFilterQuality": {"description": "Filter section - audio quality"}, - "libraryFilterQualityHiRes": "Hi-Res (24bit)", - "@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"}, - "libraryFilterQualityCD": "CD (16bit)", - "@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"}, - "libraryFilterQualityLossy": "Lossy", - "@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"}, - "libraryFilterFormat": "Format", - "@libraryFilterFormat": {"description": "Filter section - file format"}, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": {"description": "Filter section - date range"}, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": {"description": "Filter option - today only"}, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": {"description": "Filter option - this week"}, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": {"description": "Filter option - this month"}, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": {"description": "Filter option - this year"}, - "libraryFilterSort": "Sort", - "@libraryFilterSort": {"description": "Filter section - sort order"}, - "libraryFilterSortLatest": "Latest", - "@libraryFilterSortLatest": {"description": "Sort option - newest first"}, - "libraryFilterSortOldest": "Oldest", - "@libraryFilterSortOldest": {"description": "Sort option - oldest first"}, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": {"type": "int"} - } + "@libraryCleared": { + "description": "Snackbar after clearing library" + }, + "libraryStorageAccessRequired": "Storage Access Required", + "@libraryStorageAccessRequired": { + "description": "Dialog title for storage permission" + }, + "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", + "@libraryStorageAccessMessage": { + "description": "Dialog message for storage permission" + }, + "libraryFolderNotExist": "Selected folder does not exist", + "@libraryFolderNotExist": { + "description": "Error when folder doesn't exist" + }, + "librarySourceDownloaded": "Downloaded", + "@librarySourceDownloaded": { + "description": "Badge for tracks downloaded via SpotiFLAC" + }, + "librarySourceLocal": "Local", + "@librarySourceLocal": { + "description": "Badge for tracks from local library scan" + }, + "libraryFilterAll": "All", + "@libraryFilterAll": { + "description": "Filter chip - show all library items" + }, + "libraryFilterDownloaded": "Downloaded", + "@libraryFilterDownloaded": { + "description": "Filter chip - show only downloaded items" + }, + "libraryFilterLocal": "Local", + "@libraryFilterLocal": { + "description": "Filter chip - show only local library items" + }, + "libraryFilterTitle": "Filters", + "@libraryFilterTitle": { + "description": "Filter bottom sheet title" + }, + "libraryFilterReset": "Reset", + "@libraryFilterReset": { + "description": "Reset all filters button" + }, + "libraryFilterApply": "Apply", + "@libraryFilterApply": { + "description": "Apply filters button" + }, + "libraryFilterSource": "Source", + "@libraryFilterSource": { + "description": "Filter section - source type" + }, + "libraryFilterQuality": "Quality", + "@libraryFilterQuality": { + "description": "Filter section - audio quality" + }, + "libraryFilterQualityHiRes": "Hi-Res (24bit)", + "@libraryFilterQualityHiRes": { + "description": "Filter option - high resolution audio" + }, + "libraryFilterQualityCD": "CD (16bit)", + "@libraryFilterQualityCD": { + "description": "Filter option - CD quality audio" + }, + "libraryFilterQualityLossy": "Lossy", + "@libraryFilterQualityLossy": { + "description": "Filter option - lossy compressed audio" + }, + "libraryFilterFormat": "Format", + "@libraryFilterFormat": { + "description": "Filter section - file format" + }, + "libraryFilterSort": "Sort", + "@libraryFilterSort": { + "description": "Filter section - sort order" + }, + "libraryFilterSortLatest": "Latest", + "@libraryFilterSortLatest": { + "description": "Sort option - newest first" + }, + "libraryFilterSortOldest": "Oldest", + "@libraryFilterSortOldest": { + "description": "Sort option - oldest first" }, - "timeJustNow": "Just now", - "@timeJustNow": {"description": "Relative time - less than a minute ago"}, + "@timeJustNow": { + "description": "Relative time - less than a minute ago" + }, "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": {"description": "Dialog title when switching storage mode"}, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"}, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"}, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"}, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"}, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"}, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": {"type": "int"}, - "mode": {"type": "String"} - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": {"description": "Section header for new downloads info"}, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": {"type": "String"} - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": {"description": "Button to proceed with storage switch"}, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": {"description": "Button to select SAF folder"}, - "storageAppStorage": "App Storage", - "@storageAppStorage": {"description": "Label for app storage mode"}, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": {"description": "Label for SAF storage mode"}, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": {"type": "String"} - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": {"description": "Section title for storage stats"}, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": {"type": "int"} - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": {"type": "int"} - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": {"description": "Info when user has files in both storage modes"}, - "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", - "@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"}, + "@tutorialWelcomeTitle": { + "description": "Tutorial welcome page title" + }, "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", - "@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"}, + "@tutorialWelcomeDesc": { + "description": "Tutorial welcome page description" + }, "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", - "@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"}, + "@tutorialWelcomeTip1": { + "description": "Tutorial welcome tip 1" + }, "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", - "@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"}, + "@tutorialWelcomeTip2": { + "description": "Tutorial welcome tip 2" + }, "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", - "@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"}, - + "@tutorialWelcomeTip3": { + "description": "Tutorial welcome tip 3" + }, "tutorialSearchTitle": "Finding Music", - "@tutorialSearchTitle": {"description": "Tutorial search page title"}, + "@tutorialSearchTitle": { + "description": "Tutorial search page title" + }, "tutorialSearchDesc": "There are two easy ways to find music you want to download.", - "@tutorialSearchDesc": {"description": "Tutorial search page description"}, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": {"description": "Tutorial search tip 1"}, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": {"description": "Tutorial search tip 2"}, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": {"description": "Tutorial search tip 3"}, - + "@tutorialSearchDesc": { + "description": "Tutorial search page description" + }, "tutorialDownloadTitle": "Downloading Music", - "@tutorialDownloadTitle": {"description": "Tutorial download page title"}, + "@tutorialDownloadTitle": { + "description": "Tutorial download page title" + }, "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", - "@tutorialDownloadDesc": {"description": "Tutorial download page description"}, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": {"description": "Tutorial download tip 1"}, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": {"description": "Tutorial download tip 2"}, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": {"description": "Tutorial download tip 3"}, - + "@tutorialDownloadDesc": { + "description": "Tutorial download page description" + }, "tutorialLibraryTitle": "Your Library", - "@tutorialLibraryTitle": {"description": "Tutorial library page title"}, + "@tutorialLibraryTitle": { + "description": "Tutorial library page title" + }, "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", - "@tutorialLibraryDesc": {"description": "Tutorial library page description"}, + "@tutorialLibraryDesc": { + "description": "Tutorial library page description" + }, "tutorialLibraryTip1": "View download progress and queue in the Library tab", - "@tutorialLibraryTip1": {"description": "Tutorial library tip 1"}, + "@tutorialLibraryTip1": { + "description": "Tutorial library tip 1" + }, "tutorialLibraryTip2": "Tap any track to play it with your music player", - "@tutorialLibraryTip2": {"description": "Tutorial library tip 2"}, + "@tutorialLibraryTip2": { + "description": "Tutorial library tip 2" + }, "tutorialLibraryTip3": "Switch between list and grid view for better browsing", - "@tutorialLibraryTip3": {"description": "Tutorial library tip 3"}, - + "@tutorialLibraryTip3": { + "description": "Tutorial library tip 3" + }, "tutorialExtensionsTitle": "Extensions", - "@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"}, + "@tutorialExtensionsTitle": { + "description": "Tutorial extensions page title" + }, "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", - "@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"}, + "@tutorialExtensionsDesc": { + "description": "Tutorial extensions page description" + }, "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", - "@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"}, + "@tutorialExtensionsTip1": { + "description": "Tutorial extensions tip 1" + }, "tutorialExtensionsTip2": "Add new download providers or search sources", - "@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"}, + "@tutorialExtensionsTip2": { + "description": "Tutorial extensions tip 2" + }, "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", - "@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"}, - + "@tutorialExtensionsTip3": { + "description": "Tutorial extensions tip 3" + }, "tutorialSettingsTitle": "Customize Your Experience", - "@tutorialSettingsTitle": {"description": "Tutorial settings page title"}, + "@tutorialSettingsTitle": { + "description": "Tutorial settings page title" + }, "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", - "@tutorialSettingsDesc": {"description": "Tutorial settings page description"}, + "@tutorialSettingsDesc": { + "description": "Tutorial settings page description" + }, "tutorialSettingsTip1": "Change download location and folder organization", - "@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"}, + "@tutorialSettingsTip1": { + "description": "Tutorial settings tip 1" + }, "tutorialSettingsTip2": "Set default audio quality and format preferences", - "@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"}, + "@tutorialSettingsTip2": { + "description": "Tutorial settings tip 2" + }, "tutorialSettingsTip3": "Customize app theme and appearance", - "@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"}, - + "@tutorialSettingsTip3": { + "description": "Tutorial settings tip 3" + }, "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", - "@tutorialReadyMessage": {"description": "Tutorial completion message"}, - "tutorialExample": "EXAMPLE", - "@tutorialExample": {"description": "Example label in tutorial"}, - + "@tutorialReadyMessage": { + "description": "Tutorial completion message" + }, "libraryForceFullScan": "Force Full Scan", - "@libraryForceFullScan": {"description": "Button to force a complete rescan of library"}, + "@libraryForceFullScan": { + "description": "Button to force a complete rescan of library" + }, "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", - "@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"}, - + "@libraryForceFullScanSubtitle": { + "description": "Subtitle for force full scan button" + }, "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", - "@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"}, + "@cleanupOrphanedDownloads": { + "description": "Button to remove history entries for deleted files" + }, "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", - "@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"}, + "@cleanupOrphanedDownloadsSubtitle": { + "description": "Subtitle for orphaned cleanup button" + }, "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", "@cleanupOrphanedDownloadsResult": { "description": "Snackbar after orphan cleanup", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "cleanupOrphanedDownloadsNone": "No orphaned entries found", - "@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}, - + "@cleanupOrphanedDownloadsNone": { + "description": "Snackbar when no orphans found" + }, "cacheTitle": "Storage & Cache", - "@cacheTitle": {"description": "Cache management page title"}, + "@cacheTitle": { + "description": "Cache management page title" + }, "cacheSummaryTitle": "Cache overview", - "@cacheSummaryTitle": {"description": "Heading for cache summary card"}, + "@cacheSummaryTitle": { + "description": "Heading for cache summary card" + }, "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", - "@cacheSummarySubtitle": {"description": "Helper text for cache summary card"}, + "@cacheSummarySubtitle": { + "description": "Helper text for cache summary card" + }, "cacheEstimatedTotal": "Estimated cache usage: {size}", "@cacheEstimatedTotal": { "description": "Total cache size shown in summary", "placeholders": { - "size": {"type": "String"} + "size": { + "type": "String" + } } }, "cacheSectionStorage": "Cached Data", - "@cacheSectionStorage": {"description": "Section header for cache entries"}, + "@cacheSectionStorage": { + "description": "Section header for cache entries" + }, "cacheSectionMaintenance": "Maintenance", - "@cacheSectionMaintenance": {"description": "Section header for cleanup actions"}, + "@cacheSectionMaintenance": { + "description": "Section header for cleanup actions" + }, "cacheAppDirectory": "App cache directory", - "@cacheAppDirectory": {"description": "Cache item title for app cache directory"}, + "@cacheAppDirectory": { + "description": "Cache item title for app cache directory" + }, "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", - "@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"}, + "@cacheAppDirectoryDesc": { + "description": "Description of what app cache directory contains" + }, "cacheTempDirectory": "Temporary directory", - "@cacheTempDirectory": {"description": "Cache item title for temporary files directory"}, + "@cacheTempDirectory": { + "description": "Cache item title for temporary files directory" + }, "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", - "@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"}, + "@cacheTempDirectoryDesc": { + "description": "Description of what temporary directory contains" + }, "cacheCoverImage": "Cover image cache", - "@cacheCoverImage": {"description": "Cache item title for persistent cover images"}, + "@cacheCoverImage": { + "description": "Cache item title for persistent cover images" + }, "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", - "@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"}, + "@cacheCoverImageDesc": { + "description": "Description of what cover image cache contains" + }, "cacheLibraryCover": "Library cover cache", - "@cacheLibraryCover": {"description": "Cache item title for local library cover art images"}, + "@cacheLibraryCover": { + "description": "Cache item title for local library cover art images" + }, "cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.", - "@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"}, + "@cacheLibraryCoverDesc": { + "description": "Description of what library cover cache contains" + }, "cacheExploreFeed": "Explore feed cache", - "@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"}, + "@cacheExploreFeed": { + "description": "Cache item title for explore home feed cache" + }, "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", - "@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"}, + "@cacheExploreFeedDesc": { + "description": "Description of what explore feed cache contains" + }, "cacheTrackLookup": "Track lookup cache", - "@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"}, + "@cacheTrackLookup": { + "description": "Cache item title for track ID lookup cache" + }, "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", - "@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"}, + "@cacheTrackLookupDesc": { + "description": "Description of what track lookup cache contains" + }, "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", - "@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"}, + "@cacheCleanupUnusedDesc": { + "description": "Description of what cleanup unused data does" + }, "cacheNoData": "No cached data", - "@cacheNoData": {"description": "Label when cache category has no data"}, + "@cacheNoData": { + "description": "Label when cache category has no data" + }, "cacheSizeWithFiles": "{size} in {count} files", "@cacheSizeWithFiles": { "description": "Cache size and file count", "placeholders": { - "size": {"type": "String"}, - "count": {"type": "int"} + "size": { + "type": "String" + }, + "count": { + "type": "int" + } } }, "cacheSizeOnly": "{size}", "@cacheSizeOnly": { "description": "Cache size only", "placeholders": { - "size": {"type": "String"} + "size": { + "type": "String" + } } }, "cacheEntries": "{count} entries", "@cacheEntries": { "description": "Track cache entry count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "cacheClearSuccess": "Cleared: {target}", "@cacheClearSuccess": { "description": "Snackbar after clearing selected cache", "placeholders": { - "target": {"type": "String"} + "target": { + "type": "String" + } } }, "cacheClearConfirmTitle": "Clear cache?", - "@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"}, + "@cacheClearConfirmTitle": { + "description": "Dialog title before clearing one cache category" + }, "cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.", "@cacheClearConfirmMessage": { "description": "Dialog message before clearing selected cache", "placeholders": { - "target": {"type": "String"} + "target": { + "type": "String" + } } }, "cacheClearAllConfirmTitle": "Clear all cache?", - "@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"}, + "@cacheClearAllConfirmTitle": { + "description": "Dialog title before clearing all caches" + }, "cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.", - "@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"}, + "@cacheClearAllConfirmMessage": { + "description": "Dialog message before clearing all caches" + }, "cacheClearAll": "Clear all cache", - "@cacheClearAll": {"description": "Button label to clear all caches"}, + "@cacheClearAll": { + "description": "Button label to clear all caches" + }, "cacheCleanupUnused": "Cleanup unused data", - "@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"}, + "@cacheCleanupUnused": { + "description": "Action title for cleaning unused entries" + }, "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", - "@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"}, + "@cacheCleanupUnusedSubtitle": { + "description": "Subtitle for cleanup unused data action" + }, "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", "@cacheCleanupResult": { "description": "Snackbar after unused data cleanup", "placeholders": { - "downloadCount": {"type": "int"}, - "libraryCount": {"type": "int"} + "downloadCount": { + "type": "int" + }, + "libraryCount": { + "type": "int" + } } }, "cacheRefreshStats": "Refresh stats", - "@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}, - + "@cacheRefreshStats": { + "description": "Button label to refresh cache statistics" + }, "trackSaveCoverArt": "Save Cover Art", - "@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"}, + "@trackSaveCoverArt": { + "description": "Menu action - save album cover art as file" + }, "trackSaveCoverArtSubtitle": "Save album art as .jpg file", - "@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"}, + "@trackSaveCoverArtSubtitle": { + "description": "Subtitle for save cover art action" + }, "trackSaveLyrics": "Save Lyrics (.lrc)", - "@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"}, + "@trackSaveLyrics": { + "description": "Menu action - save lyrics as .lrc file" + }, "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", - "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, + "@trackSaveLyricsSubtitle": { + "description": "Subtitle for save lyrics action" + }, "trackSaveLyricsProgress": "Saving lyrics...", - "@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"}, + "@trackSaveLyricsProgress": { + "description": "Snackbar while saving lyrics to file" + }, "trackReEnrich": "Re-enrich", - "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"}, + "@trackReEnrich": { + "description": "Menu action - re-embed metadata into audio file" + }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", - "@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"}, + "@trackReEnrichOnlineSubtitle": { + "description": "Subtitle for re-enrich metadata action for local items" + }, "trackEditMetadata": "Edit Metadata", - "@trackEditMetadata": {"description": "Menu action - edit embedded metadata"}, + "@trackEditMetadata": { + "description": "Menu action - edit embedded metadata" + }, "trackCoverSaved": "Cover art saved to {fileName}", "@trackCoverSaved": { "description": "Snackbar after cover art saved", "placeholders": { - "fileName": {"type": "String"} + "fileName": { + "type": "String" + } } }, "trackCoverNoSource": "No cover art source available", - "@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"}, + "@trackCoverNoSource": { + "description": "Snackbar when no cover art URL or embedded cover" + }, "trackLyricsSaved": "Lyrics saved to {fileName}", "@trackLyricsSaved": { "description": "Snackbar after lyrics saved", "placeholders": { - "fileName": {"type": "String"} + "fileName": { + "type": "String" + } } }, "trackReEnrichProgress": "Re-enriching metadata...", - "@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"}, + "@trackReEnrichProgress": { + "description": "Snackbar while re-enriching metadata" + }, "trackReEnrichSearching": "Searching metadata online...", - "@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"}, + "@trackReEnrichSearching": { + "description": "Snackbar while searching metadata from internet for local items" + }, "trackReEnrichSuccess": "Metadata re-enriched successfully", - "@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"}, + "@trackReEnrichSuccess": { + "description": "Snackbar after successful re-enrichment" + }, "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", - "@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"}, + "@trackReEnrichFfmpegFailed": { + "description": "Snackbar when FFmpeg embed fails for MP3/Opus" + }, "trackSaveFailed": "Failed: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, - "trackConvertFormat": "Convert Format", - "@trackConvertFormat": {"description": "Menu item - convert audio format"}, + "@trackConvertFormat": { + "description": "Menu item - convert audio format" + }, "trackConvertFormatSubtitle": "Convert to MP3 or Opus", - "@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"}, + "@trackConvertFormatSubtitle": { + "description": "Subtitle for convert format menu item" + }, "trackConvertTitle": "Convert Audio", - "@trackConvertTitle": {"description": "Title of convert bottom sheet"}, + "@trackConvertTitle": { + "description": "Title of convert bottom sheet" + }, "trackConvertTargetFormat": "Target Format", - "@trackConvertTargetFormat": {"description": "Label for format selection"}, + "@trackConvertTargetFormat": { + "description": "Label for format selection" + }, "trackConvertBitrate": "Bitrate", - "@trackConvertBitrate": {"description": "Label for bitrate selection"}, + "@trackConvertBitrate": { + "description": "Label for bitrate selection" + }, "trackConvertConfirmTitle": "Confirm Conversion", - "@trackConvertConfirmTitle": {"description": "Confirmation dialog title"}, + "@trackConvertConfirmTitle": { + "description": "Confirmation dialog title" + }, "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", "@trackConvertConfirmMessage": { "description": "Confirmation dialog message", "placeholders": { - "sourceFormat": {"type": "String"}, - "targetFormat": {"type": "String"}, - "bitrate": {"type": "String"} + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + }, + "bitrate": { + "type": "String" + } } }, "trackConvertConverting": "Converting audio...", - "@trackConvertConverting": {"description": "Snackbar while converting"}, + "@trackConvertConverting": { + "description": "Snackbar while converting" + }, "trackConvertSuccess": "Converted to {format} successfully", "@trackConvertSuccess": { "description": "Snackbar after successful conversion", "placeholders": { - "format": {"type": "String"} + "format": { + "type": "String" + } } }, "trackConvertFailed": "Conversion failed", - "@trackConvertFailed": {"description": "Snackbar when conversion fails"}, - + "@trackConvertFailed": { + "description": "Snackbar when conversion fails" + }, "actionCreate": "Create", - "@actionCreate": {"description": "Generic action button - create"}, - + "@actionCreate": { + "description": "Generic action button - create" + }, "collectionFoldersTitle": "My folders", - "@collectionFoldersTitle": {"description": "Library section title for custom folders"}, + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, "collectionWishlist": "Wishlist", - "@collectionWishlist": {"description": "Custom folder for saved tracks to download later"}, + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, "collectionLoved": "Loved", - "@collectionLoved": {"description": "Custom folder for favorite tracks"}, + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, "collectionPlaylists": "Playlists", - "@collectionPlaylists": {"description": "Custom user playlists folder"}, + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, "collectionPlaylist": "Playlist", - "@collectionPlaylist": {"description": "Single playlist label"}, + "@collectionPlaylist": { + "description": "Single playlist label" + }, "collectionAddToPlaylist": "Add to playlist", - "@collectionAddToPlaylist": {"description": "Action to add a track to user playlist"}, + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, "collectionCreatePlaylist": "Create playlist", - "@collectionCreatePlaylist": {"description": "Action to create a new playlist"}, + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, "collectionNoPlaylistsYet": "No playlists yet", - "@collectionNoPlaylistsYet": {"description": "Empty state title when user has no playlists"}, + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, "collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks", - "@collectionNoPlaylistsSubtitle": {"description": "Empty state subtitle when user has no playlists"}, + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, "collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}", "@collectionPlaylistTracks": { "description": "Track count label for custom playlists", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "collectionAddedToPlaylist": "Added to \"{playlistName}\"", "@collectionAddedToPlaylist": { "description": "Snackbar after adding track to playlist", "placeholders": { - "playlistName": {"type": "String"} + "playlistName": { + "type": "String" + } } }, "collectionAlreadyInPlaylist": "Already in \"{playlistName}\"", "@collectionAlreadyInPlaylist": { "description": "Snackbar when track already exists in playlist", "placeholders": { - "playlistName": {"type": "String"} + "playlistName": { + "type": "String" + } } }, "collectionPlaylistCreated": "Playlist created", - "@collectionPlaylistCreated": {"description": "Snackbar after creating playlist"}, + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, "collectionPlaylistNameHint": "Playlist name", - "@collectionPlaylistNameHint": {"description": "Hint text for playlist name input"}, + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, "collectionPlaylistNameRequired": "Playlist name is required", - "@collectionPlaylistNameRequired": {"description": "Validation error for empty playlist name"}, + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, "collectionRenamePlaylist": "Rename playlist", - "@collectionRenamePlaylist": {"description": "Action to rename playlist"}, + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, "collectionDeletePlaylist": "Delete playlist", - "@collectionDeletePlaylist": {"description": "Action to delete playlist"}, + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, "collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?", "@collectionDeletePlaylistMessage": { "description": "Confirmation message for deleting playlist", "placeholders": { - "playlistName": {"type": "String"} + "playlistName": { + "type": "String" + } } }, "collectionPlaylistDeleted": "Playlist deleted", - "@collectionPlaylistDeleted": {"description": "Snackbar after deleting playlist"}, + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, "collectionPlaylistRenamed": "Playlist renamed", - "@collectionPlaylistRenamed": {"description": "Snackbar after renaming playlist"}, + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, "collectionWishlistEmptyTitle": "Wishlist is empty", - "@collectionWishlistEmptyTitle": {"description": "Wishlist empty state title"}, + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, "collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later", - "@collectionWishlistEmptySubtitle": {"description": "Wishlist empty state subtitle"}, + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, "collectionLovedEmptyTitle": "Loved folder is empty", - "@collectionLovedEmptyTitle": {"description": "Loved empty state title"}, + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, "collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites", - "@collectionLovedEmptySubtitle": {"description": "Loved empty state subtitle"}, + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, "collectionPlaylistEmptyTitle": "Playlist is empty", - "@collectionPlaylistEmptyTitle": {"description": "Playlist empty state title"}, + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, "collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here", - "@collectionPlaylistEmptySubtitle": {"description": "Playlist empty state subtitle"}, + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, "collectionRemoveFromPlaylist": "Remove from playlist", - "@collectionRemoveFromPlaylist": {"description": "Tooltip for removing track from playlist"}, + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, "collectionRemoveFromFolder": "Remove from folder", - "@collectionRemoveFromFolder": {"description": "Tooltip for removing track from wishlist/loved folder"}, + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, "collectionRemoved": "\"{trackName}\" removed", "@collectionRemoved": { "description": "Snackbar after removing a track from a collection", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "collectionAddedToLoved": "\"{trackName}\" added to Loved", "@collectionAddedToLoved": { "description": "Snackbar after adding track to loved folder", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "collectionRemovedFromLoved": "\"{trackName}\" removed from Loved", "@collectionRemovedFromLoved": { "description": "Snackbar after removing track from loved folder", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "collectionAddedToWishlist": "\"{trackName}\" added to Wishlist", "@collectionAddedToWishlist": { "description": "Snackbar after adding track to wishlist", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist", "@collectionRemovedFromWishlist": { "description": "Snackbar after removing track from wishlist", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, - "trackOptionAddToLoved": "Add to Loved", - "@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"}, + "@trackOptionAddToLoved": { + "description": "Bottom sheet action label - add track to loved folder" + }, "trackOptionRemoveFromLoved": "Remove from Loved", - "@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"}, + "@trackOptionRemoveFromLoved": { + "description": "Bottom sheet action label - remove track from loved folder" + }, "trackOptionAddToWishlist": "Add to Wishlist", - "@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"}, + "@trackOptionAddToWishlist": { + "description": "Bottom sheet action label - add track to wishlist" + }, "trackOptionRemoveFromWishlist": "Remove from Wishlist", - "@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"}, - + "@trackOptionRemoveFromWishlist": { + "description": "Bottom sheet action label - remove track from wishlist" + }, "collectionPlaylistChangeCover": "Change cover image", - "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, + "@collectionPlaylistChangeCover": { + "description": "Bottom sheet action to pick a custom cover image for a playlist" + }, "collectionPlaylistRemoveCover": "Remove cover image", - "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}, - + "@collectionPlaylistRemoveCover": { + "description": "Bottom sheet action to remove custom cover image from a playlist" + }, "selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "selectionShareNoFiles": "No shareable files found", - "@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"}, + "@selectionShareNoFiles": { + "description": "Snackbar when no selected files exist on disk" + }, "selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "selectionConvertNoConvertible": "No convertible tracks selected", - "@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"}, + "@selectionConvertNoConvertible": { + "description": "Snackbar when no selected tracks support conversion" + }, "selectionBatchConvertConfirmTitle": "Batch Convert", - "@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"}, + "@selectionBatchConvertConfirmTitle": { + "description": "Confirmation dialog title for batch conversion" + }, "selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.", "@selectionBatchConvertConfirmMessage": { "description": "Confirmation dialog message for batch conversion", "placeholders": { - "count": {"type": "int"}, - "format": {"type": "String"}, - "bitrate": {"type": "String"} + "count": { + "type": "int" + }, + "format": { + "type": "String" + }, + "bitrate": { + "type": "String" + } } }, "selectionBatchConvertProgress": "Converting {current} of {total}...", "@selectionBatchConvertProgress": { "description": "Snackbar during batch conversion progress", "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}", "@selectionBatchConvertSuccess": { "description": "Snackbar after batch conversion completes", "placeholders": { - "success": {"type": "int"}, - "total": {"type": "int"}, - "format": {"type": "String"} + "success": { + "type": "int" + }, + "total": { + "type": "int" + }, + "format": { + "type": "String" + } } }, - - "setupModeSelectionTitle": "Choose Your Mode", - "@setupModeSelectionTitle": {"description": "Title for mode selection step in setup wizard"}, - "setupModeSelectionDescription": "How would you like to use SpotiFLAC? You can always change this later in Settings.", - "@setupModeSelectionDescription": {"description": "Description for mode selection step"}, - "setupModeDownloaderTitle": "Downloader", - "@setupModeDownloaderTitle": {"description": "Title for downloader mode option"}, - "setupModeDownloaderFeature1": "Download tracks in lossless FLAC quality", - "@setupModeDownloaderFeature1": {"description": "Downloader mode feature 1"}, - "setupModeDownloaderFeature2": "Save music to your device for offline listening", - "@setupModeDownloaderFeature2": {"description": "Downloader mode feature 2"}, - "setupModeDownloaderFeature3": "Manage your local music library", - "@setupModeDownloaderFeature3": {"description": "Downloader mode feature 3"}, - "setupModeStreamingTitle": "Streaming", - "@setupModeStreamingTitle": {"description": "Title for streaming mode option"}, - "setupModeStreamingFeature1": "Stream tracks instantly without downloading", - "@setupModeStreamingFeature1": {"description": "Streaming mode feature 1"}, - "setupModeStreamingFeature2": "Smart Queue auto-discovers new music for you", - "@setupModeStreamingFeature2": {"description": "Streaming mode feature 2"}, - "setupModeStreamingFeature3": "Play any track on demand with playback controls", - "@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"}, - "setupModeChangeableLater": "You can switch between modes anytime in Settings.", - "@setupModeChangeableLater": {"description": "Hint that mode can be changed later"} + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index f53b9487..497c11bc 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -5,18 +5,10 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -29,20 +21,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -55,24 +33,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -85,48 +45,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "settingsTitle": "Settings", "@settingsTitle": { "description": "Settings screen title" @@ -155,34 +73,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -195,38 +85,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -247,10 +109,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -267,10 +125,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -426,22 +280,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -468,10 +306,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -544,10 +378,6 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -564,14 +394,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -584,35 +406,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -625,43 +418,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -678,54 +434,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -734,10 +446,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -769,10 +477,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -817,26 +521,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -857,14 +541,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -873,58 +549,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -933,10 +565,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -945,26 +573,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -977,26 +593,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1025,34 +625,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1173,15 +749,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1242,16 +809,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1265,34 +822,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1305,14 +834,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1321,14 +842,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1350,19 +863,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1403,55 +903,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1492,27 +947,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1553,14 +991,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1581,14 +1011,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1613,22 +1035,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1661,22 +1067,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1689,60 +1079,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1849,22 +1185,6 @@ "@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" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -1893,10 +1213,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2019,15 +1335,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2063,22 +1370,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2107,18 +1398,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2338,14 +1617,6 @@ "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2354,66 +1625,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2422,18 +1633,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2442,38 +1641,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2519,19 +1686,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2562,19 +1716,13 @@ "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, - "setupModeSelectionTitle": "Elige tu modo", - "setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.", - "setupModeDownloaderTitle": "Descargador", - "setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida", - "setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión", - "setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", - "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", - "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", - "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + } +} diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 6065fd9d..304b9cd8 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Inicio", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Historial", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Ajustes", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Pegar URL Spotify o buscar...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Buscar con {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Pegar enlace de Spotify o buscar por nombre", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Historial", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Descargando ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Descargado", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Todo", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {1 pista} other{{count} pistas}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {1 álbum} other{{count} álbumes}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No hay historial de descargas", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Las pistas descargadas aparecerán aquí", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No hay descargas de álbum", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Descargar múltiples pistas de un álbum para verlas aquí", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No hay descargas", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Las descargas de una sola pista aparecerán aquí", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Buscar en historial...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Ubicación de descarga", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Elija dónde guardar los archivos", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Ubicación predeterminada", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Servicio por defecto", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Servicio usado para descargas", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Calidad por defecto", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Preguntar calidad antes de descargar", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Mostrar selector de calidad para cada descarga", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separar Pistas", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Colocar pistas individuales en una carpeta separada", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Mejor disponible", - "@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": "Apariencia", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistema", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Color Secundario", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Vista de Historial", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Buscar Fuente", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Proveedor Principal", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Extensiones instaladas", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No hay extensiones instaladas", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Instalar extensiones desde la pestaña Tienda", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Habilitado", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Deshabilitado", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Establecer como proveedor de búsqueda", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Tienda de extensiones", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Soporte", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Aplicación", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "Música DAB", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Álbum", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {1 pista} other{{count} pistas}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Descargar Todo", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Descargas Restantes", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Lista de reproducción", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artista", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Álbumes", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {1 lanzamiento} other{{count} lanzamientos}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Populares", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Información de pista", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artista", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Álbum", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duración", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Calidad", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Ruta del archivo", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Descargado", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Servicio", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Volver a descargar", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Abrir carpeta", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Bienvenido a SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Comencemos", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Permiso de almacenamiento", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Necesario para guardar los archivos descargados", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permiso aprobado", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permiso denegado", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Conceder permiso", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Ubicación de descarga", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Seleccionar Carpeta", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continuar", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Omitir por ahora", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Seleccionar carpeta de descarga", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "¿Usar carpeta por defecto?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Almacenamiento", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notificación", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Carpeta", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permiso", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "¡Permiso de almacenamiento concedido!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Recibe notificaciones cuando las descargas completen o requieran atención.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "¡Carpeta de descarga seleccionada!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Cambiar carpeta de descargas", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Cambiar carpeta", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Seleccionar Carpeta", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "API de Spotify (opcional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Usar API de Spotify", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Ingresa tus credenciales a continuación", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Usando Deezer (no se necesita cuenta)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Introduzca el ID de cliente de Spotify", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Ingresa el Client Secret de Spotify", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Activar notificaciones", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Ahora puedes continuar con el siguiente paso.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Recibirás notificaciones de progreso de descargas.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Atrás", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Siguiente", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Saltar y empezar", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Obtener credenciales de developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancelar", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "Aceptar", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Guardar", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Cerrar", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Sí", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Borrar", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirmar", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Hecho", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Descarga fallida", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Pista:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artista:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Eliminar todo", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "¿Estás seguro de que quieres borrar todas las descargas?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "¿Eliminar del dispositivo?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Eliminar extensión", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Error al cargar: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "URL {platform} copiada al portapapeles", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Error al cargar {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No se encontraron pistas", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "En cola", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Descargando", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizando", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completado", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Error", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Omitido", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Pausado", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pausar", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Detener", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Seleccionar", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Seleccionar Todo", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Pegar", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Importar CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Eliminar credenciales", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Toca las pistas para seleccionar", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {pista} other{pistas}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Seleccionar pistas a eliminar", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancelar", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Detener", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Volver a intentar", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Eliminar", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Borrar", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Pegar", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Formato del nombre del archivo", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Vista previa: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Marcadores disponibles:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Organización de carpetas", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Ninguna organización", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Versión {version} está disponible", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Descargar", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Más tarde", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Historial de cambios", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Iniciando descarga...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Prioridad del proveedor", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Arrastre para reordenar los proveedores de descarga", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Prioridad del proveedor", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Prioridad del proveedor de metadatos", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Orden usado al recuperar metadatos de la pista", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Prioridad de los metadatos", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copiar Registros", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Limpiar registros", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Compartir Registros", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No hay registros aún", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Registros copiados al portapapeles", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "BLOQUEO POR EL ISP DETECTADO", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "TASA LIMITADA", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ERROR DE RED", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "PISTA NO ENCONTRADA", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filtrar los registros por gravedad", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Resumen de Incidencias", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Tu ISP puede estar bloqueando el acceso a los servicios de descarga", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Demasiadas solicitudes al servicio", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Espere unos minutos antes de volver a intentarlo", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Problemas de conexión detectados", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Comprueba tu conexión a internet", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "No se pudieron encontrar algunas pistas en los servicios de descarga", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "La pista puede no estar disponible en calidad sin pérdida", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total de errores: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Afectado: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entradas ({count} filtradas)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Elija su idioma preferido", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Tema, colores, pantalla", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Pistas", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Descargar Todo ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "No se puede abrir: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Hoy", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Secuencial", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 simultáneamente", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 simultáneamente", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Pulse para ver los detalles del error", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Todo", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No se encontraron extensiones", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Prioridad del proveedor", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Instalar extensión", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Por defecto (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Con pérdidas", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (convertido desde FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (convertido de FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Habilitar opción con pérdida", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "La opción de calidad con pérdida está disponible", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Descargas FLAC y luego se convierten en formato con pérdida", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Formato con Perdido", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Elegir el formato con pérdida para la conversión", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, mejor compatibilidad", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, mejor calidad a menor tamaño", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "La calidad real depende de la disponibilidad de la pista del servicio", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Guardar Formato", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Seleccionar Servicio", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Seleccionar Calidad", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Calidad por Defecto", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "La mejor disponible", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Ninguna", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Guardar todos los archivos directamente para descargar la carpeta", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artista", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Nombre del Artista/nombre de archivo", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Álbum", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Nombre del álbum/nombre de archivo", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artista/Álbum", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Nombre del Artista/Nombre del Álbum/Nombre del Archivo", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Oscuro", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Elegir color principal", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Modo de tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Descargas en proceso", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Eliminar todo", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Exportar", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Descarga fallida exportada al archivo TXT", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Limpieza Fallida", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Error al exportar descargas", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Autoexportar descargas fallidas", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No hay descargas en cola", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Añadir pistas desde la pantalla de inicio", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Limpiar tareas finalizadas", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Descarga fallida", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Pista:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artista:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Error desconocido", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artista / Álbum", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Pistas", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} descargado", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} seleccionado", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Funciones de utilidad", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artista", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Descargar Discografía", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Elige tu modo", - "setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.", - "setupModeDownloaderTitle": "Descargador", - "setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida", - "setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión", - "setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", - "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", - "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", - "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} descargado", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index d52346ef..f7e9c2d1 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Accueil", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Historique", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Paramètres", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Coller l'URL Spotify ou rechercher...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Rechercher avec {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Coller un lien Spotify ou rechercher par nom", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Historique", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Téléchargement ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Téléchargé", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Tous", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Pas d'historique de téléchargement", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Les pistes téléchargées apparaîtront ici", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Pas de téléchargement d'album", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Téléchargez plusieurs titres d'un album pour les voir ici", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Pas de téléchargements uniques", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Les téléchargements de pistes uniques apparaîtront ici", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Historique de recherche...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Télécharger Localisation", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choisissez où enregistrer des fichiers", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Localisation par défaut", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Service par défaut", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service utilisé pour les téléchargements", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Qualité par défaut", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Demandez La Qualité Avant Le Téléchargement", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Afficher le sélecteur de qualité pour chaque téléchargement", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Titres séparés", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Mettre des pistes uniques dans un dossier séparé", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Meilleur Disponible", - "@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": "Apparence", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Thème", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Système", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Couleur d'accent", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Historique Vue", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Recherche Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Fournisseur principal", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Défini comme fournisseur de recherche", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Magasin d'extension", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-télécharger", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Dossier ouvert", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Bienvenue chez SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "On va commencer", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Permission de stockage", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Requis pour enregistrer les fichiers téléchargés", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission accordée", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission refusée", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Dossier de téléchargement sélectionné!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choisissez le dossier pour télécharger", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Choisissez votre mode", - "setupModeSelectionDescription": "Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.", - "setupModeDownloaderTitle": "Téléchargeur", - "setupModeDownloaderFeature1": "Téléchargez des pistes en qualité FLAC sans perte", - "setupModeDownloaderFeature2": "Enregistrez de la musique sur votre appareil pour une écoute hors ligne", - "setupModeDownloaderFeature3": "Gérez votre bibliothèque musicale locale", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Diffusez des pistes instantanément sans télécharger", - "setupModeStreamingFeature2": "Smart Queue découvre automatiquement de nouvelle musique pour vous", - "setupModeStreamingFeature3": "Écoutez n'importe quelle piste à la demande avec les contrôles de lecture", - "setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index e57da31b..064202f2 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "होम", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "इतिहास", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "विकल्प", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "दिखावट", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "अपना मोड चुनें", - "setupModeSelectionDescription": "आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।", - "setupModeDownloaderTitle": "डाउनलोडर", - "setupModeDownloaderFeature1": "लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें", - "setupModeDownloaderFeature2": "ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें", - "setupModeDownloaderFeature3": "अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें", - "setupModeStreamingTitle": "स्ट्रीमिंग", - "setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें", - "setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है", - "setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं", - "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।" -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index cf4de43b..0d7caf67 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,305 +1,159 @@ -{ - "@@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" - }, - "navLibrary": "Library", - "@navLibrary": { - "description": "Bottom navigation - Library tab" - }, - "navHistory": "Riwayat", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, - "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" - }, - "historySearchHint": "Search history...", - "@historySearchHint": { - "description": "Search bar placeholder in history" - }, - "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" - }, +{ + "@@locale": "id", + "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, + "navHome": "Beranda", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, + "navLibrary": "Library", + "@navLibrary": { + "description": "Bottom navigation - Library 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" + }, + "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" + }, + "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" + }, + "historySearchHint": "Search history...", + "@historySearchHint": { + "description": "Search bar placeholder in history" + }, + "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" + }, + "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" + }, + "appearanceTitle": "Tampilan", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, + "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" + }, + "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" + }, + "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" @@ -308,1265 +162,834 @@ "@optionsAutoFallbackSubtitle": { "description": "Subtitle for auto fallback" }, - "optionsAutoSkipUnavailableTracks": "Lewati Otomatis Lagu yang Tidak Tersedia", - "@optionsAutoSkipUnavailableTracks": { - "description": "Toggle to skip to the next queue track when current track stream resolution fails" - }, - "optionsAutoSkipUnavailableTracksSubtitleOn": "Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.", - "@optionsAutoSkipUnavailableTracksSubtitleOn": { - "description": "Subtitle when auto skip on resolve failure is enabled" - }, - "optionsAutoSkipUnavailableTracksSubtitleOff": "Berhenti di lagu yang gagal dan tampilkan pesan error.", - "@optionsAutoSkipUnavailableTracksSubtitleOff": { - "description": "Subtitle when auto skip on resolve failure is disabled" - }, - "optionsInteractionMode": "Mode Interaksi", - "@optionsInteractionMode": { - "description": "Tap behavior mode for track lists" - }, - "modeDownloader": "Mode Downloader", - "@modeDownloader": { - "description": "Interaction mode where taps queue downloads" - }, - "modeDownloaderSubtitle": "Ketuk lagu untuk menambah ke antrean unduhan", - "@modeDownloaderSubtitle": { - "description": "Subtitle for downloader interaction mode" - }, - "modeStreaming": "Mode Streaming", - "@modeStreaming": { - "description": "Interaction mode where taps start playback" - }, - "modeStreamingSubtitle": "Ketuk lagu untuk langsung memutar", - "@modeStreamingSubtitle": { - "description": "Subtitle for streaming interaction mode" - }, "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" - }, - "optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", - "@optionsSpotifyDeprecationWarning": { - "description": "Warning about Spotify API deprecation" - }, - "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" - }, - "aboutTranslators": "Translators", - "@aboutTranslators": { - "description": "Section for translators" - }, - "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" - }, - "aboutTelegramChannel": "Telegram Channel", - "@aboutTelegramChannel": { - "description": "Link to Telegram channel" - }, - "aboutTelegramChannelSubtitle": "Announcements and updates", - "@aboutTelegramChannelSubtitle": { - "description": "Subtitle for Telegram channel" - }, - "aboutTelegramChat": "Telegram Community", - "@aboutTelegramChat": { - "description": "Link to Telegram chat group" - }, - "aboutTelegramChatSubtitle": "Chat with other users", - "@aboutTelegramChatSubtitle": { - "description": "Subtitle for Telegram chat" - }, - "aboutSocial": "Social", - "@aboutSocial": { - "description": "Section for social links" - }, - "aboutSupport": "Dukungan", - "@aboutSupport": { - "description": "Section for support/donation links" - }, - "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" - }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, - "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" - }, - "aboutSpotiSaver": "SpotiSaver", - "@aboutSpotiSaver": { - "description": "Name of SpotiSaver API service - DO NOT TRANSLATE" - }, - "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", - "@aboutSpotiSaverDesc": { - "description": "Credit for SpotiSaver API" - }, - "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - "@aboutAppDescription": { - "description": "App description in header card" - }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Unduh Semua", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Unduh Sisanya", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artis", - "@artistTitle": { - "description": "Artist screen title" - }, - "artistAlbums": "Album", - "@artistAlbums": { - "description": "Section header for artist albums" - }, - "artistSingles": "Single & EP", - "@artistSingles": { - "description": "Section header for singles/EPs" - }, - "artistCompilations": "Kompilasi", - "@artistCompilations": { - "description": "Section header for compilations" - }, - "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "artistPopular": "Populer", - "@artistPopular": { - "description": "Section header for popular/top tracks" - }, - "artistMonthlyListeners": "{count} pendengar bulanan", - "@artistMonthlyListeners": { - "description": "Monthly listener count display", - "placeholders": { - "count": { - "type": "String", - "description": "Formatted listener count" - } - } - }, - "trackMetadataTitle": "Info Lagu", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artis", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Durasi", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Kualitas", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Lokasi File", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Diunduh", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, - "trackMetadataService": "Layanan", - "@trackMetadataService": { - "description": "Metadata field - download service used" - }, - "trackMetadataPlay": "Putar", - "@trackMetadataPlay": { - "description": "Action button - play track" - }, - "trackMetadataShare": "Bagikan", - "@trackMetadataShare": { - "description": "Action button - share track" - }, - "trackMetadataDelete": "Hapus", - "@trackMetadataDelete": { - "description": "Action button - delete track" - }, - "trackMetadataRedownload": "Unduh ulang", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Buka Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Selamat Datang di SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Mari mulai pengaturan", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Izin Penyimpanan", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Izin diberikan", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Izin ditolak", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, - "setupGrantPermission": "Berikan Izin", - "@setupGrantPermission": { - "description": "Button to request permission" - }, - "setupDownloadLocation": "Lokasi Unduhan", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Pilih Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Lanjutkan", - "@setupContinue": { - "description": "Continue to next step button" - }, - "setupSkip": "Lewati untuk sekarang", - "@setupSkip": { - "description": "Skip current step button" - }, - "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", - "@setupStorageAccessRequired": { - "description": "Title when storage access needed" - }, - "setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, - "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", - "@setupStorageAccessMessageAndroid11": { - "description": "Android 11+ specific explanation" - }, - "setupOpenSettings": "Buka Pengaturan", - "@setupOpenSettings": { - "description": "Button to open system settings" - }, - "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", - "@setupPermissionDeniedMessage": { - "description": "Error when permission denied" - }, - "setupPermissionRequired": "Izin {permissionType} Diperlukan", - "@setupPermissionRequired": { - "description": "Generic permission required title", - "placeholders": { - "permissionType": { - "type": "String", - "description": "Type of permission (Storage/Notification)" - } - } - }, - "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", - "@setupPermissionRequiredMessage": { - "description": "Generic permission required message", - "placeholders": { - "permissionType": { - "type": "String" - } - } - }, - "setupSelectDownloadFolder": "Pilih Folder Unduhan", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, - "setupUseDefaultFolder": "Gunakan Folder Default?", - "@setupUseDefaultFolder": { - "description": "Dialog title for default folder" - }, - "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", - "@setupNoFolderSelected": { - "description": "Prompt when no folder selected" - }, - "setupUseDefault": "Gunakan Default", - "@setupUseDefault": { - "description": "Button to use default folder" - }, - "setupDownloadLocationTitle": "Lokasi Unduhan", - "@setupDownloadLocationTitle": { - "description": "Download location dialog title" - }, - "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", - "@setupDownloadLocationIosMessage": { - "description": "iOS-specific folder info" - }, - "setupAppDocumentsFolder": "Folder Documents Aplikasi", - "@setupAppDocumentsFolder": { - "description": "iOS documents folder option" - }, - "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", - "@setupAppDocumentsFolderSubtitle": { - "description": "Subtitle for documents folder" - }, - "setupChooseFromFiles": "Pilih dari Files", - "@setupChooseFromFiles": { - "description": "iOS file picker option" - }, - "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", - "@setupChooseFromFilesSubtitle": { - "description": "Subtitle for file picker" - }, - "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", - "@setupIosEmptyFolderWarning": { - "description": "iOS folder selection warning" - }, - "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", - "@setupIcloudNotSupported": { - "description": "Error when user selects iCloud Drive on iOS" - }, - "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?", - "csvImportTracks": "{count} tracks from CSV", - "@csvImportTracks": { - "description": "Label shown in quality picker for CSV import", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "@dialogImportPlaylistMessage": { - "description": "Dialog message - import playlist confirmation", - "placeholders": { - "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" - } - } - }, - "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", - "@snackbarAlreadyInLibrary": { - "description": "Snackbar - track already exists in local library", - "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)" - } - } - }, + "@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" + }, + "optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", + "@optionsSpotifyDeprecationWarning": { + "description": "Warning about Spotify API deprecation" + }, + "extensionsTitle": "Ekstensi", + "@extensionsTitle": { + "description": "Extensions page title" + }, + "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" + }, + "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" + }, + "aboutTranslators": "Translators", + "@aboutTranslators": { + "description": "Section for translators" + }, + "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" + }, + "aboutTelegramChannel": "Telegram Channel", + "@aboutTelegramChannel": { + "description": "Link to Telegram channel" + }, + "aboutTelegramChannelSubtitle": "Announcements and updates", + "@aboutTelegramChannelSubtitle": { + "description": "Subtitle for Telegram channel" + }, + "aboutTelegramChat": "Telegram Community", + "@aboutTelegramChat": { + "description": "Link to Telegram chat group" + }, + "aboutTelegramChatSubtitle": "Chat with other users", + "@aboutTelegramChatSubtitle": { + "description": "Subtitle for Telegram chat" + }, + "aboutSocial": "Social", + "@aboutSocial": { + "description": "Section for social links" + }, + "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" + }, + "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", + "@aboutSjdonadoDesc": { + "description": "Credit description for sjdonado" + }, + "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" + }, + "aboutSpotiSaver": "SpotiSaver", + "@aboutSpotiSaver": { + "description": "Name of SpotiSaver API service - DO NOT TRANSLATE" + }, + "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", + "@aboutSpotiSaverDesc": { + "description": "Credit for SpotiSaver API" + }, + "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "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" + }, + "artistPopular": "Populer", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} pendengar bulanan", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "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" + }, + "setupGrantPermission": "Berikan Izin", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupSkip": "Lewati untuk sekarang", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "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" + } + } + }, + "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" + }, + "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", + "@setupIcloudNotSupported": { + "description": "Error when user selects iCloud Drive on iOS" + }, + "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "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" + }, + "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" + }, + "setupSelectFolder": "Pilih Folder", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupEnableNotifications": "Aktifkan Notifikasi", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "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" + }, + "setupNext": "Lanjut", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Mulai", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "dialogCancel": "Batal", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "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" + }, + "dialogClear": "Hapus", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "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" + }, + "dialogClearAll": "Hapus Semua", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "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?", + "csvImportTracks": "{count} tracks from CSV", + "@csvImportTracks": { + "description": "Label shown in quality picker for CSV import", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "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" + } + } + }, + "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", + "@snackbarAlreadyInLibrary": { + "description": "Snackbar - track already exists in local library", + "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" + }, + "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" + }, "errorNoTracksFound": "Tidak ada lagu ditemukan", "@errorNoTracksFound": { "description": "Error - search returned no results" }, - "errorSeekNotSupported": "Menggeser posisi lagu tidak didukung untuk live stream ini", - "@errorSeekNotSupported": { - "description": "Error - seek disabled for live decrypted stream" - }, "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" + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" + "actionPause": "Jeda", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Lanjutkan", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Batal", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionSelectAll": "Pilih Semua", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Batal Pilih", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "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" + }, + "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" + }, + "filenameFormat": "Format Nama File", + "@filenameFormat": { + "description": "Setting title - filename pattern" }, "filenameShowAdvancedTags": "Tampilkan tag lanjutan", "@filenameShowAdvancedTags": { @@ -1576,479 +999,348 @@ "@filenameShowAdvancedTagsDescription": { "description": "Description for advanced filename tag toggle" }, - "folderOrganization": "Organisasi Folder", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, - "folderOrganizationNone": "Tidak ada", - "@folderOrganizationNone": { - "description": "Folder option - flat structure" - }, - "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" - }, - "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" - }, - "sectionLyrics": "Lyrics", - "@sectionLyrics": { - "description": "Settings section header" - }, - "lyricsMode": "Lyrics Mode", - "@lyricsMode": { - "description": "Setting - how to save lyrics" - }, - "lyricsModeDescription": "Choose how lyrics are saved with your downloads", - "@lyricsModeDescription": { - "description": "Lyrics mode picker description" - }, - "lyricsModeEmbed": "Embed in file", - "@lyricsModeEmbed": { - "description": "Lyrics mode option - embed in audio file" - }, - "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", - "@lyricsModeEmbedSubtitle": { - "description": "Subtitle for embed option" - }, - "lyricsModeExternal": "External .lrc file", - "@lyricsModeExternal": { - "description": "Lyrics mode option - separate LRC file" - }, - "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", - "@lyricsModeExternalSubtitle": { - "description": "Subtitle for external option" - }, - "lyricsModeBoth": "Both", - "@lyricsModeBoth": { - "description": "Lyrics mode option - embed and external" - }, - "lyricsModeBothSubtitle": "Embed and save .lrc file", - "@lyricsModeBothSubtitle": { - "description": "Subtitle for both option" - }, - "sectionColor": "Warna", - "@sectionColor": { - "description": "Settings section header" - }, - "sectionTheme": "Tema", - "@sectionTheme": { - "description": "Settings section header" - }, - "sectionLayout": "Tata Letak", - "@sectionLayout": { - "description": "Settings section header" - }, - "sectionLanguage": "Bahasa", - "@sectionLanguage": { - "description": "Settings section header for language" - }, - "appearanceLanguage": "Bahasa Aplikasi", - "@appearanceLanguage": { - "description": "Language setting title" - }, - "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, - "settingsAppearanceSubtitle": "Tema, warna, tampilan", - "@settingsAppearanceSubtitle": { - "description": "Appearance settings description" - }, - "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", - "@settingsDownloadSubtitle": { - "description": "Download settings description" - }, - "settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan", - "@settingsOptionsSubtitle": { - "description": "Options settings description" - }, - "settingsExtensionsSubtitle": "Kelola provider unduhan", - "@settingsExtensionsSubtitle": { - "description": "Extensions settings description" - }, - "settingsLogsSubtitle": "Lihat log aplikasi untuk debugging", - "@settingsLogsSubtitle": { - "description": "Logs settings description" - }, - "loadingSharedLink": "Memuat link yang dibagikan...", - "@loadingSharedLink": { - "description": "Status when opening shared URL" - }, - "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", - "@pressBackAgainToExit": { - "description": "Exit confirmation message" - }, - "tracksHeader": "Lagu", - "@tracksHeader": { - "description": "Section header for track list" - }, + "folderOrganizationNone": "Tidak ada", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, + "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" + }, + "updateAvailable": "Pembaruan Tersedia", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateLater": "Nanti", + "@updateLater": { + "description": "Update button - dismiss" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "sectionLyrics": "Lyrics", + "@sectionLyrics": { + "description": "Settings section header" + }, + "lyricsMode": "Lyrics Mode", + "@lyricsMode": { + "description": "Setting - how to save lyrics" + }, + "lyricsModeDescription": "Choose how lyrics are saved with your downloads", + "@lyricsModeDescription": { + "description": "Lyrics mode picker description" + }, + "lyricsModeEmbed": "Embed in file", + "@lyricsModeEmbed": { + "description": "Lyrics mode option - embed in audio file" + }, + "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", + "@lyricsModeEmbedSubtitle": { + "description": "Subtitle for embed option" + }, + "lyricsModeExternal": "External .lrc file", + "@lyricsModeExternal": { + "description": "Lyrics mode option - separate LRC file" + }, + "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", + "@lyricsModeExternalSubtitle": { + "description": "Subtitle for external option" + }, + "lyricsModeBoth": "Both", + "@lyricsModeBoth": { + "description": "Lyrics mode option - embed and external" + }, + "lyricsModeBothSubtitle": "Embed and save .lrc file", + "@lyricsModeBothSubtitle": { + "description": "Subtitle for both option" + }, + "sectionColor": "Warna", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Tema", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Tata Letak", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Bahasa", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "Bahasa Aplikasi", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "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" + }, "downloadAllCount": "Unduh Semua ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2058,491 +1350,405 @@ } } }, - "playAllCount": "Putar Semua ({count})", - "@playAllCount": { - "description": "Play all button with count", + "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@tracksCount": { + "description": "Track count display", "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" - }, - "trackGenre": "Genre", - "@trackGenre": { - "description": "Metadata label - music genre" - }, - "trackLabel": "Label", - "@trackLabel": { - "description": "Metadata label - record label" - }, - "trackCopyright": "Copyright", - "@trackCopyright": { - "description": "Metadata label - copyright information" - }, - "trackDownloaded": "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" - }, - "trackEmbedLyrics": "Embed Lyrics", - "@trackEmbedLyrics": { - "description": "Action - embed lyrics into audio file" - }, - "trackLyricsEmbedded": "Lyrics embedded successfully", - "@trackLyricsEmbedded": { - "description": "Snackbar - lyrics saved to file" - }, - "trackInstrumental": "Instrumental track", - "@trackInstrumental": { - "description": "Message when track is instrumental (no lyrics)" - }, - "trackCopiedToClipboard": "Disalin ke clipboard", - "@trackCopiedToClipboard": { - "description": "Snackbar - content copied" - }, - "trackDeleteConfirmTitle": "Hapus dari perangkat?", - "@trackDeleteConfirmTitle": { - "description": "Delete confirmation title" - }, - "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", - "@trackDeleteConfirmMessage": { - "description": "Delete confirmation message" - }, - "trackCannotOpen": "Tidak dapat membuka: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, - "dateToday": "Hari ini", - "@dateToday": { - "description": "Relative date - today" - }, - "dateYesterday": "Kemarin", - "@dateYesterday": { - "description": "Relative date - yesterday" - }, - "dateDaysAgo": "{count} hari lalu", - "@dateDaysAgo": { - "description": "Relative date - days ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dateWeeksAgo": "{count} minggu lalu", - "@dateWeeksAgo": { - "description": "Relative date - weeks ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dateMonthsAgo": "{count} bulan lalu", - "@dateMonthsAgo": { - "description": "Relative date - months ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "concurrentSequential": "Berurutan", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Paralel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Paralel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Ketuk untuk melihat detail error", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, - "storeFilterAll": "Semua", - "@storeFilterAll": { - "description": "Store filter - all extensions" - }, - "storeFilterMetadata": "Metadata", - "@storeFilterMetadata": { - "description": "Store filter - metadata providers" - }, - "storeFilterDownload": "Unduhan", - "@storeFilterDownload": { - "description": "Store filter - download providers" - }, - "storeFilterUtility": "Utilitas", - "@storeFilterUtility": { - "description": "Store filter - utility extensions" - }, - "storeFilterLyrics": "Lirik", - "@storeFilterLyrics": { - "description": "Store filter - lyrics providers" - }, - "storeFilterIntegration": "Integrasi", - "@storeFilterIntegration": { - "description": "Store filter - integrations" - }, - "storeClearFilters": "Hapus filter", - "@storeClearFilters": { - "description": "Button to clear all filters" - }, - "storeNoResults": "Tidak ada ekstensi ditemukan", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Prioritas Provider", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Pasang Ekstensi", - "@extensionInstallButton": { - "description": "Button to install extension" - }, - "extensionDefaultProvider": "Default (Deezer/Spotify)", - "@extensionDefaultProvider": { - "description": "Default search provider option" - }, - "extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan", - "@extensionDefaultProviderSubtitle": { - "description": "Subtitle for default provider" - }, - "extensionAuthor": "Pembuat", - "@extensionAuthor": { - "description": "Extension detail - author" - }, - "extensionId": "ID", - "@extensionId": { - "description": "Extension detail - unique ID" - }, - "extensionError": "Error", - "@extensionError": { - "description": "Extension detail - error message" - }, - "extensionCapabilities": "Kemampuan", - "@extensionCapabilities": { - "description": "Section header - extension features" - }, - "extensionMetadataProvider": "Provider Metadata", - "@extensionMetadataProvider": { - "description": "Capability - provides metadata" - }, - "extensionDownloadProvider": "Provider Unduhan", - "@extensionDownloadProvider": { - "description": "Capability - provides downloads" - }, - "extensionLyricsProvider": "Provider Lirik", - "@extensionLyricsProvider": { - "description": "Capability - provides lyrics" - }, - "extensionUrlHandler": "Penanganan URL", - "@extensionUrlHandler": { - "description": "Capability - handles URLs" - }, - "extensionQualityOptions": "Opsi Kualitas", - "@extensionQualityOptions": { - "description": "Capability - quality selection" - }, - "extensionPostProcessingHooks": "Hook Pasca-Pemrosesan", - "@extensionPostProcessingHooks": { - "description": "Capability - post-processing" - }, - "extensionPermissions": "Izin", - "@extensionPermissions": { - "description": "Section header - required permissions" - }, - "extensionSettings": "Pengaturan", - "@extensionSettings": { - "description": "Section header - extension settings" - }, - "extensionRemoveButton": "Hapus Ekstensi", - "@extensionRemoveButton": { - "description": "Button to uninstall extension" - }, - "extensionUpdated": "Diperbarui", - "@extensionUpdated": { - "description": "Extension detail - last update" - }, - "extensionMinAppVersion": "Versi App Minimum", - "@extensionMinAppVersion": { - "description": "Extension detail - minimum app version" - }, - "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", - "@extensionCustomTrackMatching": { - "description": "Capability - custom track matching algorithm" - }, - "extensionPostProcessing": "Pasca-Pemrosesan", - "@extensionPostProcessing": { - "description": "Capability - post-download processing" - }, - "extensionHooksAvailable": "{count} hook tersedia", - "@extensionHooksAvailable": { - "description": "Post-processing hooks count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "extensionPatternsCount": "{count} pola", - "@extensionPatternsCount": { - "description": "URL patterns count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "extensionStrategy": "Strategi: {strategy}", - "@extensionStrategy": { - "description": "Track matching strategy name", - "placeholders": { - "strategy": { - "type": "String" - } - } - }, - "extensionsProviderPrioritySection": "Prioritas Provider", - "@extensionsProviderPrioritySection": { - "description": "Section header - provider priority" - }, - "extensionsInstalledSection": "Ekstensi Terpasang", - "@extensionsInstalledSection": { - "description": "Section header - installed extensions" - }, - "extensionsNoExtensions": "Tidak ada ekstensi terpasang", - "@extensionsNoExtensions": { - "description": "Empty state - no extensions" - }, - "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", - "@extensionsNoExtensionsSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsInstallButton": "Pasang Ekstensi", - "@extensionsInstallButton": { - "description": "Button to install extension from file" - }, - "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", - "@extensionsInfoTip": { - "description": "Security warning about extensions" - }, - "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", - "@extensionsInstalledSuccess": { - "description": "Success message after install" - }, - "extensionsDownloadPriority": "Prioritas Unduhan", - "@extensionsDownloadPriority": { - "description": "Setting - download provider order" - }, - "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", - "@extensionsDownloadPrioritySubtitle": { - "description": "Subtitle for download priority" - }, - "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", - "@extensionsNoDownloadProvider": { - "description": "Empty state - no download providers" - }, - "extensionsMetadataPriority": "Prioritas Metadata", - "@extensionsMetadataPriority": { - "description": "Setting - metadata provider order" - }, - "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", - "@extensionsMetadataPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, - "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", - "@extensionsNoMetadataProvider": { - "description": "Empty state - no metadata providers" - }, - "extensionsSearchProvider": "Provider Pencarian", - "@extensionsSearchProvider": { - "description": "Setting - search provider selection" - }, - "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", - "@extensionsNoCustomSearch": { - "description": "Empty state - no search providers" - }, - "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", - "@extensionsSearchProviderDescription": { - "description": "Search provider setting description" - }, - "extensionsCustomSearch": "Pencarian kustom", - "@extensionsCustomSearch": { - "description": "Label for custom search provider" - }, - "extensionsErrorLoading": "Error memuat ekstensi", - "@extensionsErrorLoading": { - "description": "Error message when extension fails to load" - }, - "qualityFlacLossless": "FLAC Lossless", - "@qualityFlacLossless": { - "description": "Quality option - CD quality FLAC" - }, - "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", - "@qualityFlacLosslessSubtitle": { - "description": "Technical spec for lossless" - }, - "qualityHiResFlac": "Hi-Res FLAC", - "@qualityHiResFlac": { - "description": "Quality option - high resolution FLAC" - }, - "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", - "@qualityHiResFlacSubtitle": { - "description": "Technical spec for hi-res" - }, - "qualityHiResFlacMax": "Hi-Res FLAC Max", - "@qualityHiResFlacMax": { - "description": "Quality option - maximum resolution FLAC" - }, - "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", - "@qualityHiResFlacMaxSubtitle": { - "description": "Technical spec for hi-res max" - }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, + "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" + }, + "trackGenre": "Genre", + "@trackGenre": { + "description": "Metadata label - music genre" + }, + "trackLabel": "Label", + "@trackLabel": { + "description": "Metadata label - record label" + }, + "trackCopyright": "Copyright", + "@trackCopyright": { + "description": "Metadata label - copyright information" + }, + "trackDownloaded": "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" + }, + "trackEmbedLyrics": "Embed Lyrics", + "@trackEmbedLyrics": { + "description": "Action - embed lyrics into audio file" + }, + "trackLyricsEmbedded": "Lyrics embedded successfully", + "@trackLyricsEmbedded": { + "description": "Snackbar - lyrics saved to file" + }, + "trackInstrumental": "Instrumental track", + "@trackInstrumental": { + "description": "Message when track is instrumental (no lyrics)" + }, + "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" + }, + "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" + } + } + }, + "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" + }, + "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" @@ -2559,686 +1765,455 @@ "@youtubeMp3BitrateTitle": { "description": "Title for YouTube MP3 bitrate setting" }, - "youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})", - "@youtubeBitrateSubtitle": { - "description": "Subtitle showing current bitrate and valid range", - "placeholders": { - "bitrate": { - "type": "int" - }, - "min": { - "type": "int" - }, - "max": { - "type": "int" - } - } - }, - "youtubeBitrateInputHelp": "Masukkan bitrate manual ({min}-{max} kbps)", - "@youtubeBitrateInputHelp": { - "description": "Helper text for manual YouTube bitrate input", - "placeholders": { - "min": { - "type": "int" - }, - "max": { - "type": "int" - } - } - }, - "youtubeBitrateFieldLabel": "Bitrate (kbps)", - "@youtubeBitrateFieldLabel": { - "description": "Label for YouTube bitrate input field" - }, - "youtubeBitrateValidationError": "Bitrate harus antara {min} dan {max} kbps", - "@youtubeBitrateValidationError": { - "description": "Validation error for invalid YouTube bitrate input", - "placeholders": { - "min": { - "type": "int" - }, - "max": { - "type": "int" - } - } - }, "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" - }, - "downloadUseAlbumArtistForFolders": "Use Album Artist for folders", - "@downloadUseAlbumArtistForFolders": { - "description": "Setting - choose whether artist folders use Album Artist or Track Artist" - }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, - "downloadUsePrimaryArtistOnly": "Primary artist only for folders", - "@downloadUsePrimaryArtistOnly": { - "description": "Setting - strip featured artists from folder name" - }, - "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", - "@downloadUsePrimaryArtistOnlyEnabled": { - "description": "Subtitle when primary artist only is enabled" - }, - "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", - "@downloadUsePrimaryArtistOnlyDisabled": { - "description": "Subtitle when primary artist only is disabled" - }, - "downloadSaveFormat": "Simpan Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Pilih Layanan", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, - "downloadSelectQuality": "Pilih Kualitas", - "@downloadSelectQuality": { - "description": "Dialog title - choose audio quality" - }, - "downloadFrom": "Unduh Dari", - "@downloadFrom": { - "description": "Label - download source" - }, - "downloadDefaultQualityLabel": "Kualitas Default", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Terbaik tersedia", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Tidak ada", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artis", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Nama Artis/namafile", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Nama Album/namafile", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artis/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, - "appearanceAmoledDark": "AMOLED Gelap", - "@appearanceAmoledDark": { - "description": "Theme option - pure black" - }, - "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", - "@appearanceAmoledDarkSubtitle": { - "description": "Subtitle for AMOLED dark" - }, - "appearanceChooseAccentColor": "Pilih Warna Aksen", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Mode Tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Antrian Unduhan", - "@queueTitle": { - "description": "Queue screen title" - }, - "queueClearAll": "Hapus Semua", - "@queueClearAll": { - "description": "Button - clear all queue items" - }, - "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", - "@queueClearAllMessage": { - "description": "Clear queue confirmation" - }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, - "settingsAutoExportFailed": "Auto-export failed downloads", - "@settingsAutoExportFailed": { - "description": "Setting toggle for auto-export" - }, - "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", - "@settingsAutoExportFailedSubtitle": { - "description": "Subtitle for auto-export setting" - }, - "settingsDownloadNetwork": "Download Network", - "@settingsDownloadNetwork": { - "description": "Setting for network type preference" - }, - "settingsDownloadNetworkAny": "WiFi + Mobile Data", - "@settingsDownloadNetworkAny": { - "description": "Network option - use any connection" - }, - "settingsDownloadNetworkWifiOnly": "WiFi Only", - "@settingsDownloadNetworkWifiOnly": { - "description": "Network option - only use WiFi" - }, - "settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", - "@settingsDownloadNetworkSubtitle": { - "description": "Subtitle explaining network preference" - }, - "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" - }, - "albumFolderArtistAlbumSingles": "Artist / Album + Singles", - "@albumFolderArtistAlbumSingles": { - "description": "Album folder option with singles inside artist" - }, - "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", - "@albumFolderArtistAlbumSinglesSubtitle": { - "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" - }, - "downloadedAlbumDiscHeader": "Disc {discNumber}", - "@downloadedAlbumDiscHeader": { - "description": "Header for disc separator in multi-disc albums", - "placeholders": { - "discNumber": { - "type": "int", - "example": "1" - } - } - }, - "utilityFunctions": "Fungsi Utilitas", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, - "recentTypeArtist": "Artis", - "@recentTypeArtist": { - "description": "Recent access item type - artist" - }, - "recentTypeAlbum": "Album", - "@recentTypeAlbum": { - "description": "Recent access item type - album" - }, - "recentTypeSong": "Lagu", - "@recentTypeSong": { - "description": "Recent access item type - song/track" - }, - "recentTypePlaylist": "Playlist", - "@recentTypePlaylist": { - "description": "Recent access item type - playlist" - }, - "recentEmpty": "No recent items yet", - "@recentEmpty": { - "description": "Empty state text for recent access list" - }, - "recentShowAllDownloads": "Show All Downloads", - "@recentShowAllDownloads": { - "description": "Button label to unhide hidden downloads in recent access" - }, - "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" - } - } - }, - "discographyDownload": "Download Discography", - "@discographyDownload": { - "description": "Button - download artist discography" + "downloadDirectory": "Direktori Unduhan", + "@downloadDirectory": { + "description": "Setting - download folder" }, - "discographyPlay": "Putar Diskografi", - "@discographyPlay": { - "description": "Button - play artist discography" + "downloadSeparateSinglesFolder": "Folder Singles Terpisah", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" }, - "discographyDownloadAll": "Unduh Semua", - "@discographyDownloadAll": { - "description": "Option - download entire discography" + "downloadAlbumFolderStructure": "Struktur Folder Album", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" }, - "discographyPlayAll": "Putar Semua", - "@discographyPlayAll": { - "description": "Option - play entire discography" + "downloadUseAlbumArtistForFolders": "Use Album Artist for folders", + "@downloadUseAlbumArtistForFolders": { + "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", - "@discographyDownloadAllSubtitle": { - "description": "Subtitle showing total tracks and albums", - "placeholders": { - "count": { - "type": "int" - }, - "albumCount": { - "type": "int" - } - } - }, - "discographyAlbumsOnly": "Albums Only", - "@discographyAlbumsOnly": { - "description": "Option - download only albums" - }, - "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", - "@discographyAlbumsOnlySubtitle": { - "description": "Subtitle showing album tracks count", - "placeholders": { - "count": { - "type": "int" - }, - "albumCount": { - "type": "int" - } - } - }, - "discographySinglesOnly": "Singles & EPs Only", - "@discographySinglesOnly": { - "description": "Option - download only singles" - }, - "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", - "@discographySinglesOnlySubtitle": { - "description": "Subtitle showing singles tracks count", - "placeholders": { - "count": { - "type": "int" - }, - "albumCount": { - "type": "int" - } - } - }, - "discographySelectAlbums": "Select Albums...", - "@discographySelectAlbums": { - "description": "Option - manually select albums to download" - }, - "discographySelectAlbumsSubtitle": "Choose specific albums or singles", - "@discographySelectAlbumsSubtitle": { - "description": "Subtitle for select albums option" - }, - "discographyFetchingTracks": "Fetching tracks...", - "@discographyFetchingTracks": { - "description": "Progress - fetching album tracks" - }, - "discographyFetchingAlbum": "Fetching {current} of {total}...", - "@discographyFetchingAlbum": { - "description": "Progress - fetching specific album", - "placeholders": { - "current": { - "type": "int" - }, - "total": { - "type": "int" - } - } - }, - "discographySelectedCount": "{count} selected", - "@discographySelectedCount": { - "description": "Selection count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "discographyDownloadSelected": "Download Selected", - "@discographyDownloadSelected": { - "description": "Button - download selected albums" + "downloadUsePrimaryArtistOnly": "Primary artist only for folders", + "@downloadUsePrimaryArtistOnly": { + "description": "Setting - strip featured artists from folder name" }, - "discographyPlaySelected": "Putar Terpilih", - "@discographyPlaySelected": { - "description": "Button - play selected albums" + "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", + "@downloadUsePrimaryArtistOnlyEnabled": { + "description": "Subtitle when primary artist only is enabled" }, - "discographyAddedToQueue": "Added {count} tracks to queue", - "@discographyAddedToQueue": { - "description": "Snackbar - tracks added from discography", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", - "@discographySkippedDownloaded": { - "description": "Snackbar - with skipped tracks count", - "placeholders": { - "added": { - "type": "int" - }, - "skipped": { - "type": "int" - } - } - }, - "discographyNoAlbums": "No albums available", - "@discographyNoAlbums": { - "description": "Error - no albums found for artist" - }, - "discographyFailedToFetch": "Failed to fetch some albums", - "@discographyFailedToFetch": { - "description": "Error - some albums failed to load" - }, - "sectionStorageAccess": "Storage Access", - "@sectionStorageAccess": { - "description": "Section header for storage access settings" - }, - "allFilesAccess": "All Files Access", - "@allFilesAccess": { - "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" - }, - "allFilesAccessEnabledSubtitle": "Can write to any folder", - "@allFilesAccessEnabledSubtitle": { - "description": "Subtitle when all files access is enabled" - }, - "allFilesAccessDisabledSubtitle": "Limited to media folders only", - "@allFilesAccessDisabledSubtitle": { - "description": "Subtitle when all files access is disabled" - }, - "allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.", - "@allFilesAccessDescription": { - "description": "Description explaining when to enable all files access" - }, - "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", - "@allFilesAccessDeniedMessage": { - "description": "Message when permission is permanently denied" - }, - "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", - "@allFilesAccessDisabledMessage": { - "description": "Snackbar message when user disables all files access" - }, - "settingsLocalLibrary": "Local Library", - "@settingsLocalLibrary": { - "description": "Settings menu item - local library" - }, - "settingsLocalLibrarySubtitle": "Scan music & detect duplicates", - "@settingsLocalLibrarySubtitle": { - "description": "Subtitle for local library settings" - }, - "settingsCache": "Storage & Cache", - "@settingsCache": { - "description": "Settings menu item - cache management" - }, - "settingsCacheSubtitle": "View size and clear cached data", - "@settingsCacheSubtitle": { - "description": "Subtitle for cache management menu" - }, - "libraryTitle": "Local Library", - "@libraryTitle": { - "description": "Library settings page title" - }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, - "libraryScanSettings": "Scan Settings", - "@libraryScanSettings": { - "description": "Section header for scan settings" - }, - "libraryEnableLocalLibrary": "Enable Local Library", - "@libraryEnableLocalLibrary": { - "description": "Toggle to enable library scanning" - }, - "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", - "@libraryEnableLocalLibrarySubtitle": { - "description": "Subtitle for enable toggle" - }, - "libraryFolder": "Library Folder", - "@libraryFolder": { - "description": "Folder selection setting" - }, - "libraryFolderHint": "Tap to select folder", - "@libraryFolderHint": { - "description": "Placeholder when no folder selected" - }, - "libraryShowDuplicateIndicator": "Show Duplicate Indicator", - "@libraryShowDuplicateIndicator": { - "description": "Toggle for duplicate indicator in search" - }, - "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", - "@libraryShowDuplicateIndicatorSubtitle": { - "description": "Subtitle for duplicate indicator toggle" - }, - "libraryActions": "Actions", - "@libraryActions": { - "description": "Section header for library actions" - }, - "libraryScan": "Scan Library", - "@libraryScan": { - "description": "Button to start library scan" - }, - "libraryScanSubtitle": "Scan for audio files", - "@libraryScanSubtitle": { - "description": "Subtitle for scan button" - }, - "libraryScanSelectFolderFirst": "Select a folder first", - "@libraryScanSelectFolderFirst": { - "description": "Message when trying to scan without folder" - }, - "libraryCleanupMissingFiles": "Cleanup Missing Files", - "@libraryCleanupMissingFiles": { - "description": "Button to remove entries for missing files" - }, - "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", - "@libraryCleanupMissingFilesSubtitle": { - "description": "Subtitle for cleanup button" - }, - "libraryClear": "Clear Library", - "@libraryClear": { - "description": "Button to clear all library entries" - }, - "libraryClearSubtitle": "Remove all scanned tracks", - "@libraryClearSubtitle": { - "description": "Subtitle for clear button" - }, - "libraryClearConfirmTitle": "Clear Library", - "@libraryClearConfirmTitle": { - "description": "Dialog title for clear confirmation" - }, - "libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.", - "@libraryClearConfirmMessage": { - "description": "Dialog message for clear confirmation" - }, - "libraryAbout": "About Local Library", - "@libraryAbout": { - "description": "Section header for about info" - }, - "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", - "@libraryAboutDescription": { - "description": "Description of local library feature" - }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", + "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", + "@downloadUsePrimaryArtistOnlyDisabled": { + "description": "Subtitle when primary artist only is disabled" + }, + "downloadSelectQuality": "Pilih Kualitas", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Unduh Dari", + "@downloadFrom": { + "description": "Label - download source" + }, + "appearanceAmoledDark": "AMOLED Gelap", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "queueClearAll": "Hapus Semua", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "settingsAutoExportFailed": "Auto-export failed downloads", + "@settingsAutoExportFailed": { + "description": "Setting toggle for auto-export" + }, + "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", + "@settingsAutoExportFailedSubtitle": { + "description": "Subtitle for auto-export setting" + }, + "settingsDownloadNetwork": "Download Network", + "@settingsDownloadNetwork": { + "description": "Setting for network type preference" + }, + "settingsDownloadNetworkAny": "WiFi + Mobile Data", + "@settingsDownloadNetworkAny": { + "description": "Network option - use any connection" + }, + "settingsDownloadNetworkWifiOnly": "WiFi Only", + "@settingsDownloadNetworkWifiOnly": { + "description": "Network option - only use WiFi" + }, + "settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", + "@settingsDownloadNetworkSubtitle": { + "description": "Subtitle explaining network preference" + }, + "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" + }, + "albumFolderArtistAlbumSingles": "Artist / Album + Singles", + "@albumFolderArtistAlbumSingles": { + "description": "Album folder option with singles inside artist" + }, + "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", + "@albumFolderArtistAlbumSinglesSubtitle": { + "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" } } }, + "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" + }, + "downloadedAlbumDiscHeader": "Disc {discNumber}", + "@downloadedAlbumDiscHeader": { + "description": "Header for disc separator in multi-disc albums", + "placeholders": { + "discNumber": { + "type": "int", + "example": "1" + } + } + }, + "recentTypeArtist": "Artis", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Lagu", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentEmpty": "No recent items yet", + "@recentEmpty": { + "description": "Empty state text for recent access list" + }, + "recentShowAllDownloads": "Show All Downloads", + "@recentShowAllDownloads": { + "description": "Button label to unhide hidden downloads in recent access" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "discographyDownload": "Download Discography", + "@discographyDownload": { + "description": "Button - download artist discography" + }, + "discographyDownloadAll": "Unduh Semua", + "@discographyDownloadAll": { + "description": "Option - download entire discography" + }, + "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", + "@discographyDownloadAllSubtitle": { + "description": "Subtitle showing total tracks and albums", + "placeholders": { + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } + } + }, + "discographyAlbumsOnly": "Albums Only", + "@discographyAlbumsOnly": { + "description": "Option - download only albums" + }, + "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", + "@discographyAlbumsOnlySubtitle": { + "description": "Subtitle showing album tracks count", + "placeholders": { + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } + } + }, + "discographySinglesOnly": "Singles & EPs Only", + "@discographySinglesOnly": { + "description": "Option - download only singles" + }, + "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", + "@discographySinglesOnlySubtitle": { + "description": "Subtitle showing singles tracks count", + "placeholders": { + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } + } + }, + "discographySelectAlbums": "Select Albums...", + "@discographySelectAlbums": { + "description": "Option - manually select albums to download" + }, + "discographySelectAlbumsSubtitle": "Choose specific albums or singles", + "@discographySelectAlbumsSubtitle": { + "description": "Subtitle for select albums option" + }, + "discographyFetchingTracks": "Fetching tracks...", + "@discographyFetchingTracks": { + "description": "Progress - fetching album tracks" + }, + "discographyFetchingAlbum": "Fetching {current} of {total}...", + "@discographyFetchingAlbum": { + "description": "Progress - fetching specific album", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "discographySelectedCount": "{count} selected", + "@discographySelectedCount": { + "description": "Selection count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discographyDownloadSelected": "Download Selected", + "@discographyDownloadSelected": { + "description": "Button - download selected albums" + }, + "discographyAddedToQueue": "Added {count} tracks to queue", + "@discographyAddedToQueue": { + "description": "Snackbar - tracks added from discography", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", + "@discographySkippedDownloaded": { + "description": "Snackbar - with skipped tracks count", + "placeholders": { + "added": { + "type": "int" + }, + "skipped": { + "type": "int" + } + } + }, + "discographyNoAlbums": "No albums available", + "@discographyNoAlbums": { + "description": "Error - no albums found for artist" + }, + "discographyFailedToFetch": "Failed to fetch some albums", + "@discographyFailedToFetch": { + "description": "Error - some albums failed to load" + }, + "sectionStorageAccess": "Storage Access", + "@sectionStorageAccess": { + "description": "Section header for storage access settings" + }, + "allFilesAccess": "All Files Access", + "@allFilesAccess": { + "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" + }, + "allFilesAccessEnabledSubtitle": "Can write to any folder", + "@allFilesAccessEnabledSubtitle": { + "description": "Subtitle when all files access is enabled" + }, + "allFilesAccessDisabledSubtitle": "Limited to media folders only", + "@allFilesAccessDisabledSubtitle": { + "description": "Subtitle when all files access is disabled" + }, + "allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.", + "@allFilesAccessDescription": { + "description": "Description explaining when to enable all files access" + }, + "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", + "@allFilesAccessDeniedMessage": { + "description": "Message when permission is permanently denied" + }, + "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", + "@allFilesAccessDisabledMessage": { + "description": "Snackbar message when user disables all files access" + }, + "settingsLocalLibrary": "Local Library", + "@settingsLocalLibrary": { + "description": "Settings menu item - local library" + }, + "settingsLocalLibrarySubtitle": "Scan music & detect duplicates", + "@settingsLocalLibrarySubtitle": { + "description": "Subtitle for local library settings" + }, + "settingsCache": "Storage & Cache", + "@settingsCache": { + "description": "Settings menu item - cache management" + }, + "settingsCacheSubtitle": "View size and clear cached data", + "@settingsCacheSubtitle": { + "description": "Subtitle for cache management menu" + }, + "libraryTitle": "Local Library", + "@libraryTitle": { + "description": "Library settings page title" + }, + "libraryScanSettings": "Scan Settings", + "@libraryScanSettings": { + "description": "Section header for scan settings" + }, + "libraryEnableLocalLibrary": "Enable Local Library", + "@libraryEnableLocalLibrary": { + "description": "Toggle to enable library scanning" + }, + "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", + "@libraryEnableLocalLibrarySubtitle": { + "description": "Subtitle for enable toggle" + }, + "libraryFolder": "Library Folder", + "@libraryFolder": { + "description": "Folder selection setting" + }, + "libraryFolderHint": "Tap to select folder", + "@libraryFolderHint": { + "description": "Placeholder when no folder selected" + }, + "libraryShowDuplicateIndicator": "Show Duplicate Indicator", + "@libraryShowDuplicateIndicator": { + "description": "Toggle for duplicate indicator in search" + }, + "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", + "@libraryShowDuplicateIndicatorSubtitle": { + "description": "Subtitle for duplicate indicator toggle" + }, + "libraryActions": "Actions", + "@libraryActions": { + "description": "Section header for library actions" + }, + "libraryScan": "Scan Library", + "@libraryScan": { + "description": "Button to start library scan" + }, + "libraryScanSubtitle": "Scan for audio files", + "@libraryScanSubtitle": { + "description": "Subtitle for scan button" + }, + "libraryScanSelectFolderFirst": "Select a folder first", + "@libraryScanSelectFolderFirst": { + "description": "Message when trying to scan without folder" + }, + "libraryCleanupMissingFiles": "Cleanup Missing Files", + "@libraryCleanupMissingFiles": { + "description": "Button to remove entries for missing files" + }, + "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", + "@libraryCleanupMissingFilesSubtitle": { + "description": "Subtitle for cleanup button" + }, + "libraryClear": "Clear Library", + "@libraryClear": { + "description": "Button to clear all library entries" + }, + "libraryClearSubtitle": "Remove all scanned tracks", + "@libraryClearSubtitle": { + "description": "Subtitle for clear button" + }, + "libraryClearConfirmTitle": "Clear Library", + "@libraryClearConfirmTitle": { + "description": "Dialog title for clear confirmation" + }, + "libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.", + "@libraryClearConfirmMessage": { + "description": "Dialog message for clear confirmation" + }, + "libraryAbout": "About Local Library", + "@libraryAbout": { + "description": "Section header for about info" + }, + "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", + "@libraryAboutDescription": { + "description": "Description of local library feature" + }, "libraryTracksUnit": "{count, plural, =1{trek} other{trek}}", "@libraryTracksUnit": { "description": "Unit label for tracks count (without the number itself)", @@ -3248,753 +2223,591 @@ } } }, - "libraryLastScanned": "Last scanned: {time}", - "@libraryLastScanned": { - "description": "Last scan time display", - "placeholders": { - "time": { - "type": "String" - } - } - }, - "libraryLastScannedNever": "Never", - "@libraryLastScannedNever": { - "description": "Shown when library has never been scanned" - }, - "libraryScanning": "Scanning...", - "@libraryScanning": { - "description": "Status during scan" - }, - "libraryScanProgress": "{progress}% of {total} files", - "@libraryScanProgress": { - "description": "Scan progress display", - "placeholders": { - "progress": { - "type": "String" - }, - "total": { - "type": "int" - } - } - }, - "libraryInLibrary": "In Library", - "@libraryInLibrary": { - "description": "Badge shown on tracks that exist in local library" - }, - "libraryRemovedMissingFiles": "Removed {count} missing files from library", - "@libraryRemovedMissingFiles": { - "description": "Snackbar after cleanup", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "libraryCleared": "Library cleared", - "@libraryCleared": { - "description": "Snackbar after clearing library" - }, - "libraryStorageAccessRequired": "Storage Access Required", - "@libraryStorageAccessRequired": { - "description": "Dialog title for storage permission" - }, - "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", - "@libraryStorageAccessMessage": { - "description": "Dialog message for storage permission" - }, - "libraryFolderNotExist": "Selected folder does not exist", - "@libraryFolderNotExist": { - "description": "Error when folder doesn't exist" - }, - "librarySourceDownloaded": "Downloaded", - "@librarySourceDownloaded": { - "description": "Badge for tracks downloaded via SpotiFLAC" - }, - "librarySourceLocal": "Local", - "@librarySourceLocal": { - "description": "Badge for tracks from local library scan" - }, - "libraryFilterAll": "All", - "@libraryFilterAll": { - "description": "Filter chip - show all library items" - }, - "libraryFilterDownloaded": "Downloaded", - "@libraryFilterDownloaded": { - "description": "Filter chip - show only downloaded items" - }, - "libraryFilterLocal": "Local", - "@libraryFilterLocal": { - "description": "Filter chip - show only local library items" - }, - "libraryFilterTitle": "Filters", - "@libraryFilterTitle": { - "description": "Filter bottom sheet title" - }, - "libraryFilterReset": "Reset", - "@libraryFilterReset": { - "description": "Reset all filters button" - }, - "libraryFilterApply": "Apply", - "@libraryFilterApply": { - "description": "Apply filters button" - }, - "libraryFilterSource": "Source", - "@libraryFilterSource": { - "description": "Filter section - source type" - }, - "libraryFilterQuality": "Quality", - "@libraryFilterQuality": { - "description": "Filter section - audio quality" - }, - "libraryFilterQualityHiRes": "Hi-Res (24bit)", - "@libraryFilterQualityHiRes": { - "description": "Filter option - high resolution audio" - }, - "libraryFilterQualityCD": "CD (16bit)", - "@libraryFilterQualityCD": { - "description": "Filter option - CD quality audio" - }, - "libraryFilterQualityLossy": "Lossy", - "@libraryFilterQualityLossy": { - "description": "Filter option - lossy compressed audio" - }, - "libraryFilterFormat": "Format", - "@libraryFilterFormat": { - "description": "Filter section - file format" - }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, - "libraryFilterSort": "Sort", - "@libraryFilterSort": { - "description": "Filter section - sort order" - }, - "libraryFilterSortLatest": "Latest", - "@libraryFilterSortLatest": { - "description": "Sort option - newest first" - }, - "libraryFilterSortOldest": "Oldest", - "@libraryFilterSortOldest": { - "description": "Sort option - oldest first" - }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "timeJustNow": "Just now", - "@timeJustNow": { - "description": "Relative time - less than a minute ago" - }, - "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", - "@timeMinutesAgo": { - "description": "Relative time - minutes ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", - "@timeHoursAgo": { - "description": "Relative time - hours ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, - "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", - "@tutorialWelcomeTitle": { - "description": "Tutorial welcome page title" - }, - "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", - "@tutorialWelcomeDesc": { - "description": "Tutorial welcome page description" - }, - "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", - "@tutorialWelcomeTip1": { - "description": "Tutorial welcome tip 1" - }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", - "@tutorialWelcomeTip2": { - "description": "Tutorial welcome tip 2" - }, - "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", - "@tutorialWelcomeTip3": { - "description": "Tutorial welcome tip 3" - }, - "tutorialSearchTitle": "Finding Music", - "@tutorialSearchTitle": { - "description": "Tutorial search page title" - }, - "tutorialSearchDesc": "There are two easy ways to find music you want to download.", - "@tutorialSearchDesc": { - "description": "Tutorial search page description" - }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, - "tutorialDownloadTitle": "Downloading Music", - "@tutorialDownloadTitle": { - "description": "Tutorial download page title" - }, - "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", - "@tutorialDownloadDesc": { - "description": "Tutorial download page description" - }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, - "tutorialLibraryTitle": "Your Library", - "@tutorialLibraryTitle": { - "description": "Tutorial library page title" - }, - "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", - "@tutorialLibraryDesc": { - "description": "Tutorial library page description" - }, - "tutorialLibraryTip1": "View download progress and queue in the Library tab", - "@tutorialLibraryTip1": { - "description": "Tutorial library tip 1" - }, - "tutorialLibraryTip2": "Tap any track to play it with your music player", - "@tutorialLibraryTip2": { - "description": "Tutorial library tip 2" - }, - "tutorialLibraryTip3": "Switch between list and grid view for better browsing", - "@tutorialLibraryTip3": { - "description": "Tutorial library tip 3" - }, - "tutorialExtensionsTitle": "Extensions", - "@tutorialExtensionsTitle": { - "description": "Tutorial extensions page title" - }, - "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", - "@tutorialExtensionsDesc": { - "description": "Tutorial extensions page description" - }, - "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", - "@tutorialExtensionsTip1": { - "description": "Tutorial extensions tip 1" - }, - "tutorialExtensionsTip2": "Add new download providers or search sources", - "@tutorialExtensionsTip2": { - "description": "Tutorial extensions tip 2" - }, - "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", - "@tutorialExtensionsTip3": { - "description": "Tutorial extensions tip 3" - }, - "tutorialSettingsTitle": "Customize Your Experience", - "@tutorialSettingsTitle": { - "description": "Tutorial settings page title" - }, - "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", - "@tutorialSettingsDesc": { - "description": "Tutorial settings page description" - }, - "tutorialSettingsTip1": "Change download location and folder organization", - "@tutorialSettingsTip1": { - "description": "Tutorial settings tip 1" - }, - "tutorialSettingsTip2": "Set default audio quality and format preferences", - "@tutorialSettingsTip2": { - "description": "Tutorial settings tip 2" - }, - "tutorialSettingsTip3": "Customize app theme and appearance", - "@tutorialSettingsTip3": { - "description": "Tutorial settings tip 3" - }, - "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", - "@tutorialReadyMessage": { - "description": "Tutorial completion message" - }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, - "libraryForceFullScan": "Force Full Scan", - "@libraryForceFullScan": { - "description": "Button to force a complete rescan of library" - }, - "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", - "@libraryForceFullScanSubtitle": { - "description": "Subtitle for force full scan button" - }, - "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", - "@cleanupOrphanedDownloads": { - "description": "Button to remove history entries for deleted files" - }, - "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", - "@cleanupOrphanedDownloadsSubtitle": { - "description": "Subtitle for orphaned cleanup button" - }, - "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", - "@cleanupOrphanedDownloadsResult": { - "description": "Snackbar after orphan cleanup", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "cleanupOrphanedDownloadsNone": "No orphaned entries found", - "@cleanupOrphanedDownloadsNone": { - "description": "Snackbar when no orphans found" - }, - "cacheTitle": "Storage & Cache", - "@cacheTitle": { - "description": "Cache management page title" - }, - "cacheSummaryTitle": "Cache overview", - "@cacheSummaryTitle": { - "description": "Heading for cache summary card" - }, - "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", - "@cacheSummarySubtitle": { - "description": "Helper text for cache summary card" - }, - "cacheEstimatedTotal": "Estimated cache usage: {size}", - "@cacheEstimatedTotal": { - "description": "Total cache size shown in summary", - "placeholders": { - "size": { - "type": "String" - } - } - }, - "cacheSectionStorage": "Cached Data", - "@cacheSectionStorage": { - "description": "Section header for cache entries" - }, - "cacheSectionMaintenance": "Maintenance", - "@cacheSectionMaintenance": { - "description": "Section header for cleanup actions" - }, - "cacheAppDirectory": "App cache directory", - "@cacheAppDirectory": { - "description": "Cache item title for app cache directory" - }, - "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", - "@cacheAppDirectoryDesc": { - "description": "Description of what app cache directory contains" - }, - "cacheTempDirectory": "Temporary directory", - "@cacheTempDirectory": { - "description": "Cache item title for temporary files directory" - }, - "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", - "@cacheTempDirectoryDesc": { - "description": "Description of what temporary directory contains" - }, - "cacheCoverImage": "Cover image cache", - "@cacheCoverImage": { - "description": "Cache item title for persistent cover images" - }, - "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", - "@cacheCoverImageDesc": { - "description": "Description of what cover image cache contains" - }, - "cacheLibraryCover": "Library cover cache", - "@cacheLibraryCover": { - "description": "Cache item title for local library cover art images" - }, - "cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.", - "@cacheLibraryCoverDesc": { - "description": "Description of what library cover cache contains" - }, - "cacheExploreFeed": "Explore feed cache", - "@cacheExploreFeed": { - "description": "Cache item title for explore home feed cache" - }, - "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", - "@cacheExploreFeedDesc": { - "description": "Description of what explore feed cache contains" - }, - "cacheTrackLookup": "Track lookup cache", - "@cacheTrackLookup": { - "description": "Cache item title for track ID lookup cache" - }, - "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", - "@cacheTrackLookupDesc": { - "description": "Description of what track lookup cache contains" - }, - "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", - "@cacheCleanupUnusedDesc": { - "description": "Description of what cleanup unused data does" - }, - "cacheNoData": "No cached data", - "@cacheNoData": { - "description": "Label when cache category has no data" - }, - "cacheSizeWithFiles": "{size} in {count} files", - "@cacheSizeWithFiles": { - "description": "Cache size and file count", - "placeholders": { - "size": { - "type": "String" - }, - "count": { - "type": "int" - } - } - }, - "cacheSizeOnly": "{size}", - "@cacheSizeOnly": { - "description": "Cache size only", - "placeholders": { - "size": { - "type": "String" - } - } - }, - "cacheEntries": "{count} entries", - "@cacheEntries": { - "description": "Track cache entry count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "cacheClearSuccess": "Cleared: {target}", - "@cacheClearSuccess": { - "description": "Snackbar after clearing selected cache", - "placeholders": { - "target": { - "type": "String" - } - } - }, - "cacheClearConfirmTitle": "Clear cache?", - "@cacheClearConfirmTitle": { - "description": "Dialog title before clearing one cache category" - }, - "cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.", - "@cacheClearConfirmMessage": { - "description": "Dialog message before clearing selected cache", - "placeholders": { - "target": { - "type": "String" - } - } - }, - "cacheClearAllConfirmTitle": "Clear all cache?", - "@cacheClearAllConfirmTitle": { - "description": "Dialog title before clearing all caches" - }, - "cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.", - "@cacheClearAllConfirmMessage": { - "description": "Dialog message before clearing all caches" - }, - "cacheClearAll": "Clear all cache", - "@cacheClearAll": { - "description": "Button label to clear all caches" - }, - "cacheCleanupUnused": "Cleanup unused data", - "@cacheCleanupUnused": { - "description": "Action title for cleaning unused entries" - }, - "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", - "@cacheCleanupUnusedSubtitle": { - "description": "Subtitle for cleanup unused data action" - }, - "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", - "@cacheCleanupResult": { - "description": "Snackbar after unused data cleanup", - "placeholders": { - "downloadCount": { - "type": "int" - }, - "libraryCount": { - "type": "int" - } - } - }, - "cacheRefreshStats": "Refresh stats", - "@cacheRefreshStats": { - "description": "Button label to refresh cache statistics" - }, - "trackSaveCoverArt": "Save Cover Art", - "@trackSaveCoverArt": { - "description": "Menu action - save album cover art as file" - }, - "trackSaveCoverArtSubtitle": "Save album art as .jpg file", - "@trackSaveCoverArtSubtitle": { - "description": "Subtitle for save cover art action" - }, - "trackSaveLyrics": "Save Lyrics (.lrc)", - "@trackSaveLyrics": { - "description": "Menu action - save lyrics as .lrc file" - }, - "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", - "@trackSaveLyricsSubtitle": { - "description": "Subtitle for save lyrics action" - }, - "trackSaveLyricsProgress": "Saving lyrics...", - "@trackSaveLyricsProgress": { - "description": "Snackbar while saving lyrics to file" - }, - "trackReEnrich": "Re-enrich", - "@trackReEnrich": { - "description": "Menu action - re-embed metadata into audio file" - }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, - "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", - "@trackReEnrichOnlineSubtitle": { - "description": "Subtitle for re-enrich metadata action for local items" - }, - "trackEditMetadata": "Edit Metadata", - "@trackEditMetadata": { - "description": "Menu action - edit embedded metadata" - }, - "trackCoverSaved": "Cover art saved to {fileName}", - "@trackCoverSaved": { - "description": "Snackbar after cover art saved", - "placeholders": { - "fileName": { - "type": "String" - } - } - }, - "trackCoverNoSource": "No cover art source available", - "@trackCoverNoSource": { - "description": "Snackbar when no cover art URL or embedded cover" - }, - "trackLyricsSaved": "Lyrics saved to {fileName}", - "@trackLyricsSaved": { - "description": "Snackbar after lyrics saved", - "placeholders": { - "fileName": { - "type": "String" - } - } - }, - "trackReEnrichProgress": "Re-enriching metadata...", - "@trackReEnrichProgress": { - "description": "Snackbar while re-enriching metadata" - }, - "trackReEnrichSearching": "Searching metadata online...", - "@trackReEnrichSearching": { - "description": "Snackbar while searching metadata from internet for local items" - }, - "trackReEnrichSuccess": "Metadata re-enriched successfully", - "@trackReEnrichSuccess": { - "description": "Snackbar after successful re-enrichment" - }, - "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", - "@trackReEnrichFfmpegFailed": { - "description": "Snackbar when FFmpeg embed fails for MP3/Opus" - }, - "trackSaveFailed": "Failed: {error}", - "@trackSaveFailed": { - "description": "Snackbar when save operation fails", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "trackConvertFormat": "Convert Format", - "@trackConvertFormat": { - "description": "Menu item - convert audio format" - }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", - "@trackConvertFormatSubtitle": { - "description": "Subtitle for convert format menu item" - }, - "trackConvertTitle": "Convert Audio", - "@trackConvertTitle": { - "description": "Title of convert bottom sheet" - }, - "trackConvertTargetFormat": "Target Format", - "@trackConvertTargetFormat": { - "description": "Label for format selection" - }, - "trackConvertBitrate": "Bitrate", - "@trackConvertBitrate": { - "description": "Label for bitrate selection" - }, - "trackConvertConfirmTitle": "Confirm Conversion", - "@trackConvertConfirmTitle": { - "description": "Confirmation dialog title" - }, - "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", - "@trackConvertConfirmMessage": { - "description": "Confirmation dialog message", - "placeholders": { - "sourceFormat": { - "type": "String" - }, - "targetFormat": { - "type": "String" - }, - "bitrate": { - "type": "String" - } - } - }, - "trackConvertConverting": "Converting audio...", - "@trackConvertConverting": { - "description": "Snackbar while converting" - }, - "trackConvertSuccess": "Converted to {format} successfully", - "@trackConvertSuccess": { - "description": "Snackbar after successful conversion", - "placeholders": { - "format": { - "type": "String" - } - } - }, + "libraryLastScanned": "Last scanned: {time}", + "@libraryLastScanned": { + "description": "Last scan time display", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "libraryLastScannedNever": "Never", + "@libraryLastScannedNever": { + "description": "Shown when library has never been scanned" + }, + "libraryScanning": "Scanning...", + "@libraryScanning": { + "description": "Status during scan" + }, + "libraryScanProgress": "{progress}% of {total} files", + "@libraryScanProgress": { + "description": "Scan progress display", + "placeholders": { + "progress": { + "type": "String" + }, + "total": { + "type": "int" + } + } + }, + "libraryInLibrary": "In Library", + "@libraryInLibrary": { + "description": "Badge shown on tracks that exist in local library" + }, + "libraryRemovedMissingFiles": "Removed {count} missing files from library", + "@libraryRemovedMissingFiles": { + "description": "Snackbar after cleanup", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryCleared": "Library cleared", + "@libraryCleared": { + "description": "Snackbar after clearing library" + }, + "libraryStorageAccessRequired": "Storage Access Required", + "@libraryStorageAccessRequired": { + "description": "Dialog title for storage permission" + }, + "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", + "@libraryStorageAccessMessage": { + "description": "Dialog message for storage permission" + }, + "libraryFolderNotExist": "Selected folder does not exist", + "@libraryFolderNotExist": { + "description": "Error when folder doesn't exist" + }, + "librarySourceDownloaded": "Downloaded", + "@librarySourceDownloaded": { + "description": "Badge for tracks downloaded via SpotiFLAC" + }, + "librarySourceLocal": "Local", + "@librarySourceLocal": { + "description": "Badge for tracks from local library scan" + }, + "libraryFilterAll": "All", + "@libraryFilterAll": { + "description": "Filter chip - show all library items" + }, + "libraryFilterDownloaded": "Downloaded", + "@libraryFilterDownloaded": { + "description": "Filter chip - show only downloaded items" + }, + "libraryFilterLocal": "Local", + "@libraryFilterLocal": { + "description": "Filter chip - show only local library items" + }, + "libraryFilterTitle": "Filters", + "@libraryFilterTitle": { + "description": "Filter bottom sheet title" + }, + "libraryFilterReset": "Reset", + "@libraryFilterReset": { + "description": "Reset all filters button" + }, + "libraryFilterApply": "Apply", + "@libraryFilterApply": { + "description": "Apply filters button" + }, + "libraryFilterSource": "Source", + "@libraryFilterSource": { + "description": "Filter section - source type" + }, + "libraryFilterQuality": "Quality", + "@libraryFilterQuality": { + "description": "Filter section - audio quality" + }, + "libraryFilterQualityHiRes": "Hi-Res (24bit)", + "@libraryFilterQualityHiRes": { + "description": "Filter option - high resolution audio" + }, + "libraryFilterQualityCD": "CD (16bit)", + "@libraryFilterQualityCD": { + "description": "Filter option - CD quality audio" + }, + "libraryFilterQualityLossy": "Lossy", + "@libraryFilterQualityLossy": { + "description": "Filter option - lossy compressed audio" + }, + "libraryFilterFormat": "Format", + "@libraryFilterFormat": { + "description": "Filter section - file format" + }, + "libraryFilterSort": "Sort", + "@libraryFilterSort": { + "description": "Filter section - sort order" + }, + "libraryFilterSortLatest": "Latest", + "@libraryFilterSortLatest": { + "description": "Sort option - newest first" + }, + "libraryFilterSortOldest": "Oldest", + "@libraryFilterSortOldest": { + "description": "Sort option - oldest first" + }, + "timeJustNow": "Just now", + "@timeJustNow": { + "description": "Relative time - less than a minute ago" + }, + "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", + "@timeMinutesAgo": { + "description": "Relative time - minutes ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "@timeHoursAgo": { + "description": "Relative time - hours ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", + "@tutorialWelcomeTitle": { + "description": "Tutorial welcome page title" + }, + "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", + "@tutorialWelcomeDesc": { + "description": "Tutorial welcome page description" + }, + "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", + "@tutorialWelcomeTip1": { + "description": "Tutorial welcome tip 1" + }, + "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "@tutorialWelcomeTip2": { + "description": "Tutorial welcome tip 2" + }, + "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", + "@tutorialWelcomeTip3": { + "description": "Tutorial welcome tip 3" + }, + "tutorialSearchTitle": "Finding Music", + "@tutorialSearchTitle": { + "description": "Tutorial search page title" + }, + "tutorialSearchDesc": "There are two easy ways to find music you want to download.", + "@tutorialSearchDesc": { + "description": "Tutorial search page description" + }, + "tutorialDownloadTitle": "Downloading Music", + "@tutorialDownloadTitle": { + "description": "Tutorial download page title" + }, + "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", + "@tutorialDownloadDesc": { + "description": "Tutorial download page description" + }, + "tutorialLibraryTitle": "Your Library", + "@tutorialLibraryTitle": { + "description": "Tutorial library page title" + }, + "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", + "@tutorialLibraryDesc": { + "description": "Tutorial library page description" + }, + "tutorialLibraryTip1": "View download progress and queue in the Library tab", + "@tutorialLibraryTip1": { + "description": "Tutorial library tip 1" + }, + "tutorialLibraryTip2": "Tap any track to play it with your music player", + "@tutorialLibraryTip2": { + "description": "Tutorial library tip 2" + }, + "tutorialLibraryTip3": "Switch between list and grid view for better browsing", + "@tutorialLibraryTip3": { + "description": "Tutorial library tip 3" + }, + "tutorialExtensionsTitle": "Extensions", + "@tutorialExtensionsTitle": { + "description": "Tutorial extensions page title" + }, + "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", + "@tutorialExtensionsDesc": { + "description": "Tutorial extensions page description" + }, + "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", + "@tutorialExtensionsTip1": { + "description": "Tutorial extensions tip 1" + }, + "tutorialExtensionsTip2": "Add new download providers or search sources", + "@tutorialExtensionsTip2": { + "description": "Tutorial extensions tip 2" + }, + "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", + "@tutorialExtensionsTip3": { + "description": "Tutorial extensions tip 3" + }, + "tutorialSettingsTitle": "Customize Your Experience", + "@tutorialSettingsTitle": { + "description": "Tutorial settings page title" + }, + "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", + "@tutorialSettingsDesc": { + "description": "Tutorial settings page description" + }, + "tutorialSettingsTip1": "Change download location and folder organization", + "@tutorialSettingsTip1": { + "description": "Tutorial settings tip 1" + }, + "tutorialSettingsTip2": "Set default audio quality and format preferences", + "@tutorialSettingsTip2": { + "description": "Tutorial settings tip 2" + }, + "tutorialSettingsTip3": "Customize app theme and appearance", + "@tutorialSettingsTip3": { + "description": "Tutorial settings tip 3" + }, + "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", + "@tutorialReadyMessage": { + "description": "Tutorial completion message" + }, + "libraryForceFullScan": "Force Full Scan", + "@libraryForceFullScan": { + "description": "Button to force a complete rescan of library" + }, + "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", + "@libraryForceFullScanSubtitle": { + "description": "Subtitle for force full scan button" + }, + "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", + "@cleanupOrphanedDownloads": { + "description": "Button to remove history entries for deleted files" + }, + "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", + "@cleanupOrphanedDownloadsSubtitle": { + "description": "Subtitle for orphaned cleanup button" + }, + "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", + "@cleanupOrphanedDownloadsResult": { + "description": "Snackbar after orphan cleanup", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cleanupOrphanedDownloadsNone": "No orphaned entries found", + "@cleanupOrphanedDownloadsNone": { + "description": "Snackbar when no orphans found" + }, + "cacheTitle": "Storage & Cache", + "@cacheTitle": { + "description": "Cache management page title" + }, + "cacheSummaryTitle": "Cache overview", + "@cacheSummaryTitle": { + "description": "Heading for cache summary card" + }, + "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", + "@cacheSummarySubtitle": { + "description": "Helper text for cache summary card" + }, + "cacheEstimatedTotal": "Estimated cache usage: {size}", + "@cacheEstimatedTotal": { + "description": "Total cache size shown in summary", + "placeholders": { + "size": { + "type": "String" + } + } + }, + "cacheSectionStorage": "Cached Data", + "@cacheSectionStorage": { + "description": "Section header for cache entries" + }, + "cacheSectionMaintenance": "Maintenance", + "@cacheSectionMaintenance": { + "description": "Section header for cleanup actions" + }, + "cacheAppDirectory": "App cache directory", + "@cacheAppDirectory": { + "description": "Cache item title for app cache directory" + }, + "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", + "@cacheAppDirectoryDesc": { + "description": "Description of what app cache directory contains" + }, + "cacheTempDirectory": "Temporary directory", + "@cacheTempDirectory": { + "description": "Cache item title for temporary files directory" + }, + "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", + "@cacheTempDirectoryDesc": { + "description": "Description of what temporary directory contains" + }, + "cacheCoverImage": "Cover image cache", + "@cacheCoverImage": { + "description": "Cache item title for persistent cover images" + }, + "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", + "@cacheCoverImageDesc": { + "description": "Description of what cover image cache contains" + }, + "cacheLibraryCover": "Library cover cache", + "@cacheLibraryCover": { + "description": "Cache item title for local library cover art images" + }, + "cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.", + "@cacheLibraryCoverDesc": { + "description": "Description of what library cover cache contains" + }, + "cacheExploreFeed": "Explore feed cache", + "@cacheExploreFeed": { + "description": "Cache item title for explore home feed cache" + }, + "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", + "@cacheExploreFeedDesc": { + "description": "Description of what explore feed cache contains" + }, + "cacheTrackLookup": "Track lookup cache", + "@cacheTrackLookup": { + "description": "Cache item title for track ID lookup cache" + }, + "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", + "@cacheTrackLookupDesc": { + "description": "Description of what track lookup cache contains" + }, + "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", + "@cacheCleanupUnusedDesc": { + "description": "Description of what cleanup unused data does" + }, + "cacheNoData": "No cached data", + "@cacheNoData": { + "description": "Label when cache category has no data" + }, + "cacheSizeWithFiles": "{size} in {count} files", + "@cacheSizeWithFiles": { + "description": "Cache size and file count", + "placeholders": { + "size": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "cacheSizeOnly": "{size}", + "@cacheSizeOnly": { + "description": "Cache size only", + "placeholders": { + "size": { + "type": "String" + } + } + }, + "cacheEntries": "{count} entries", + "@cacheEntries": { + "description": "Track cache entry count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cacheClearSuccess": "Cleared: {target}", + "@cacheClearSuccess": { + "description": "Snackbar after clearing selected cache", + "placeholders": { + "target": { + "type": "String" + } + } + }, + "cacheClearConfirmTitle": "Clear cache?", + "@cacheClearConfirmTitle": { + "description": "Dialog title before clearing one cache category" + }, + "cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.", + "@cacheClearConfirmMessage": { + "description": "Dialog message before clearing selected cache", + "placeholders": { + "target": { + "type": "String" + } + } + }, + "cacheClearAllConfirmTitle": "Clear all cache?", + "@cacheClearAllConfirmTitle": { + "description": "Dialog title before clearing all caches" + }, + "cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.", + "@cacheClearAllConfirmMessage": { + "description": "Dialog message before clearing all caches" + }, + "cacheClearAll": "Clear all cache", + "@cacheClearAll": { + "description": "Button label to clear all caches" + }, + "cacheCleanupUnused": "Cleanup unused data", + "@cacheCleanupUnused": { + "description": "Action title for cleaning unused entries" + }, + "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", + "@cacheCleanupUnusedSubtitle": { + "description": "Subtitle for cleanup unused data action" + }, + "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", + "@cacheCleanupResult": { + "description": "Snackbar after unused data cleanup", + "placeholders": { + "downloadCount": { + "type": "int" + }, + "libraryCount": { + "type": "int" + } + } + }, + "cacheRefreshStats": "Refresh stats", + "@cacheRefreshStats": { + "description": "Button label to refresh cache statistics" + }, + "trackSaveCoverArt": "Save Cover Art", + "@trackSaveCoverArt": { + "description": "Menu action - save album cover art as file" + }, + "trackSaveCoverArtSubtitle": "Save album art as .jpg file", + "@trackSaveCoverArtSubtitle": { + "description": "Subtitle for save cover art action" + }, + "trackSaveLyrics": "Save Lyrics (.lrc)", + "@trackSaveLyrics": { + "description": "Menu action - save lyrics as .lrc file" + }, + "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", + "@trackSaveLyricsSubtitle": { + "description": "Subtitle for save lyrics action" + }, + "trackSaveLyricsProgress": "Saving lyrics...", + "@trackSaveLyricsProgress": { + "description": "Snackbar while saving lyrics to file" + }, + "trackReEnrich": "Re-enrich", + "@trackReEnrich": { + "description": "Menu action - re-embed metadata into audio file" + }, + "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", + "@trackReEnrichOnlineSubtitle": { + "description": "Subtitle for re-enrich metadata action for local items" + }, + "trackEditMetadata": "Edit Metadata", + "@trackEditMetadata": { + "description": "Menu action - edit embedded metadata" + }, + "trackCoverSaved": "Cover art saved to {fileName}", + "@trackCoverSaved": { + "description": "Snackbar after cover art saved", + "placeholders": { + "fileName": { + "type": "String" + } + } + }, + "trackCoverNoSource": "No cover art source available", + "@trackCoverNoSource": { + "description": "Snackbar when no cover art URL or embedded cover" + }, + "trackLyricsSaved": "Lyrics saved to {fileName}", + "@trackLyricsSaved": { + "description": "Snackbar after lyrics saved", + "placeholders": { + "fileName": { + "type": "String" + } + } + }, + "trackReEnrichProgress": "Re-enriching metadata...", + "@trackReEnrichProgress": { + "description": "Snackbar while re-enriching metadata" + }, + "trackReEnrichSearching": "Searching metadata online...", + "@trackReEnrichSearching": { + "description": "Snackbar while searching metadata from internet for local items" + }, + "trackReEnrichSuccess": "Metadata re-enriched successfully", + "@trackReEnrichSuccess": { + "description": "Snackbar after successful re-enrichment" + }, + "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", + "@trackReEnrichFfmpegFailed": { + "description": "Snackbar when FFmpeg embed fails for MP3/Opus" + }, + "trackSaveFailed": "Failed: {error}", + "@trackSaveFailed": { + "description": "Snackbar when save operation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "trackConvertFormat": "Convert Format", + "@trackConvertFormat": { + "description": "Menu item - convert audio format" + }, + "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "@trackConvertFormatSubtitle": { + "description": "Subtitle for convert format menu item" + }, + "trackConvertTitle": "Convert Audio", + "@trackConvertTitle": { + "description": "Title of convert bottom sheet" + }, + "trackConvertTargetFormat": "Target Format", + "@trackConvertTargetFormat": { + "description": "Label for format selection" + }, + "trackConvertBitrate": "Bitrate", + "@trackConvertBitrate": { + "description": "Label for bitrate selection" + }, + "trackConvertConfirmTitle": "Confirm Conversion", + "@trackConvertConfirmTitle": { + "description": "Confirmation dialog title" + }, + "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", + "@trackConvertConfirmMessage": { + "description": "Confirmation dialog message", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + }, + "bitrate": { + "type": "String" + } + } + }, + "trackConvertConverting": "Converting audio...", + "@trackConvertConverting": { + "description": "Snackbar while converting" + }, + "trackConvertSuccess": "Converted to {format} successfully", + "@trackConvertSuccess": { + "description": "Snackbar after successful conversion", + "placeholders": { + "format": { + "type": "String" + } + } + }, "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "actionCreate": "Buat", "@actionCreate": { "description": "Generic action button - create" @@ -4176,75 +2989,117 @@ } } }, - "trackOptionAddToLoved": "Tambahkan ke Loved", - "@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"}, + "@trackOptionAddToLoved": { + "description": "Bottom sheet action label - add track to loved folder" + }, "trackOptionRemoveFromLoved": "Hapus dari Loved", - "@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"}, + "@trackOptionRemoveFromLoved": { + "description": "Bottom sheet action label - remove track from loved folder" + }, "trackOptionAddToWishlist": "Tambahkan ke Wishlist", - "@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"}, + "@trackOptionAddToWishlist": { + "description": "Bottom sheet action label - add track to wishlist" + }, "trackOptionRemoveFromWishlist": "Hapus dari Wishlist", - "@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"}, - + "@trackOptionRemoveFromWishlist": { + "description": "Bottom sheet action label - remove track from wishlist" + }, "collectionPlaylistChangeCover": "Ubah gambar sampul", - "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, + "@collectionPlaylistChangeCover": { + "description": "Bottom sheet action to pick a custom cover image for a playlist" + }, "collectionPlaylistRemoveCover": "Hapus gambar sampul", - "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}, - "setupModeSelectionTitle": "Pilih Mode Anda", - "setupModeSelectionDescription": "Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.", - "setupModeDownloaderTitle": "Pengunduh", - "setupModeDownloaderFeature1": "Unduh trek dalam kualitas FLAC lossless", - "setupModeDownloaderFeature2": "Simpan musik ke perangkat Anda untuk mendengarkan offline", - "setupModeDownloaderFeature3": "Kelola perpustakaan musik lokal Anda", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Streaming trek secara instan tanpa mengunduh", - "setupModeStreamingFeature2": "Smart Queue secara otomatis menemukan musik baru untuk Anda", - "setupModeStreamingFeature3": "Putar trek apa pun sesuai permintaan dengan kontrol pemutaran", - "setupModeChangeableLater": "Anda dapat beralih antar mode kapan saja di Pengaturan.", + "@collectionPlaylistRemoveCover": { + "description": "Bottom sheet action to remove custom cover image from a playlist" + }, "selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "selectionShareNoFiles": "Tidak ada file yang dapat dibagikan", - "@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"}, + "@selectionShareNoFiles": { + "description": "Snackbar when no selected files exist on disk" + }, "selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih", - "@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"}, + "@selectionConvertNoConvertible": { + "description": "Snackbar when no selected tracks support conversion" + }, "selectionBatchConvertConfirmTitle": "Konversi Massal", - "@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"}, + "@selectionBatchConvertConfirmTitle": { + "description": "Confirmation dialog title for batch conversion" + }, "selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.", "@selectionBatchConvertConfirmMessage": { "description": "Confirmation dialog message for batch conversion", "placeholders": { - "count": {"type": "int"}, - "format": {"type": "String"}, - "bitrate": {"type": "String"} + "count": { + "type": "int" + }, + "format": { + "type": "String" + }, + "bitrate": { + "type": "String" + } } }, "selectionBatchConvertProgress": "Mengonversi {current} dari {total}...", "@selectionBatchConvertProgress": { "description": "Snackbar during batch conversion progress", "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}", "@selectionBatchConvertSuccess": { "description": "Snackbar after batch conversion completes", "placeholders": { - "success": {"type": "int"}, - "total": {"type": "int"}, - "format": {"type": "String"} + "success": { + "type": "int" + }, + "total": { + "type": "int" + }, + "format": { + "type": "String" + } } + }, + "downloadedAlbumDownloadedCount": "{count} diunduh", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } } diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 8d66d905..b23b0de3 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "ホーム", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "履歴", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "設定", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify の URL を貼り付けまたは検索...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "{extensionName} で検索...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify のリンクを貼り付けるか、名前で検索します", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "履歴", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "ダウンロード中 ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "ダウンロード済み", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "すべて", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 個のトラック} other{{count} 個のトラック}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 個のアルバム} other{{count} 個のアルバム}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "ダウンロード履歴はありません", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "ダウンロードしたトラックはここに表示されます", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "アルバムのダウンロードはありません", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "シングルのダウンロードはありません", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "検索履歴...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "ダウンロード先", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "ファイルの保存先を選択", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "デフォルトの場所", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "デフォルトのサービス", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "ダウンロードに使用したサービス", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "デフォルトの品質", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "ダウンロード前に品質を確認する", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "シングルを分割", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "おすすめ", - "@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": "外観", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "テーマ", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "システム", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "アクセントカラー", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "履歴の表示", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "検索ソース", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "プライマリーのプロバイダー", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "インストール済みの拡張", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "拡張はインストールされていません", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "ストアタブから拡張をインストール", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "有効", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "無効", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "検索のプロバイダーを設定", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "拡張ストア", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "サポート", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "アプリ", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "アルバム", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 個のトラック} other{{count} 個のトラック}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "すべてダウンロード", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "プレイリスト", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "アーティスト", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "アルバム", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 個のリリース} other{{count} 個のリリース}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "人気", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "トラック情報", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "アーティスト", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "アルバム", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "再生時間", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "品質", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "ファイルパス", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "ダウンロード済み", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "サービス", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "再ダウンロード", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "フォルダを開く", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "SpotiFLAC へようこそ", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "ストレージの権限", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "ダウンロードしたファイルを保存するために必要です", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "権限を許可しました", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "権限が拒否されました", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "権限を許可", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "ダウンロード先", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "フォルダを選択", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "続行", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "今はスキップ", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "ダウンロードフォルダを選択", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "デフォルトのフォルダを使用しますか?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "ストレージ", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "通知", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "フォルダ", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "権限", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "ストレージの権限が許可されました!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "ダウンロードフォルダが選択済みです!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "ダウンロードフォルダを選択", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "フォルダを変更", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "フォルダを選択", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (任意)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Spotify API を使用する", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "以下に認証情報を入力してください", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Deezer を使用中 (アカウントは不要です)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Spotify クライアント ID を入力", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Spotify クライアントシークレットを入力", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Spotify 開発者ダッシュボードから無料の API 認証情報を取得します。", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "通知を有効化する", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "戻る", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "次へ", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "スキップと開始", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "developer.spotify.com から認証情報を取得します", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "キャンセル", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "保存", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "閉じる", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "はい", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "いいえ", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "消去", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "続行", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "完了", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "ダウンロードに失敗しました", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "トラック:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "アーティスト:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "エラー:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "すべて消去", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "デバイスから削除しますか?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "拡張を削除", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "読み込みに失敗: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} の URL をクリップボードにコピーしました", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "トラックがありません", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "キュー済み", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "ダウンロード中", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "終了処理中", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "完了しました", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "失敗しました", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "スキップしました", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "一時停止中", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "一時停止", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "停止", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "選択", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "すべて選択", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "貼り付け", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "CSV をインポート", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "認証情報を削除", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "トラックをタップで選択", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を削除", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "トラックを選択で削除", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "キャンセル", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "停止", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "再試行", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "削除", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "消去", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "貼り付け", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "ファイル名の形式", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "プレビュー: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "利用可能なプレースホルダー:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "フォルダ構成", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "構成がありません", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "バージョン {version} が利用可能です", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "ダウンロード", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "後で", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "更新履歴", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "ダウンロードを開始中...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "プロバイダーの優先度", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "ドラッグでダウンロードプロバイダーを並べ替え", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "プロバイダーの優先度", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "メタデータプロバイダーの優先度", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "メタデータの優先度", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "ログをコピー", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "ログを消去", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "ログを共有", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "まだログはありません", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "ログをクリップボードにコピーしました", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP のブロックを検出しました", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "レート制限", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ネットワークエラー", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "トラックがありません", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "問題の概要", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "ISP がダウンロードサービスのアクセスをブロックしている可能性があります", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "VPN を使用するか DNS を 1.1.1.1 または 8.8.8.8 に変更をお試しください", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "サービスへのリクエストが多すぎます", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "接続の問題が検出されました", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "インターネット接続を確認してください", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "エラーの合計: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "影響: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "エントリー ({count} 個をフィルター済み)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "お好みの言語を選択してください", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "テーマ、カラー、画面", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "トラック", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "すべてダウンロード ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "開けません: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "今日", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 並列", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 並列", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "タップでエラーの詳細を表示", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "すべて", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "拡張がありません", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "プロバイダーの優先度", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "拡張をインストール", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "デフォルト (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "実際の品質はサービスからのトラックの可用性に依存します", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "形式を保存", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "サービスを選択", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "品質を選択", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "デフォルトの品質", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "おすすめ", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "なし", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "すべてのファイルをダウンロードフォルダに保存します", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "アーティスト", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "アーティスト名/ファイル名", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "アルバム", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "アルバム名/ファイル名", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "アーティスト/アルバム", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "アーティスト名/アルバム名/ファイル名", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED ダーク", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "アクセントカラーを選択", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "テーマモード", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "ダウンロードキュー", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "すべて消去", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "キューにダウンロードがありません", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "ホーム画面からトラックを追加", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "完了済みを消去", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "ダウンロードに失敗しました", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "トラック:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "アーティスト:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "エラー:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "不明なエラー", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "アーティスト / アルバム", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "トラック", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} 個を選択済み", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "ユーティリティ機能", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "アーティスト", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "エラー: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "ディスコグラフィをダウンロード", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "モードを選択", - "setupModeSelectionDescription": "SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。", - "setupModeDownloaderTitle": "ダウンローダー", - "setupModeDownloaderFeature1": "ロスレスFLAC品質でトラックをダウンロード", - "setupModeDownloaderFeature2": "オフライン再生用に音楽をデバイスに保存", - "setupModeDownloaderFeature3": "ローカル音楽ライブラリを管理", - "setupModeStreamingTitle": "ストリーミング", - "setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング", - "setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます", - "setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生", - "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。" -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 6c3fa14d..22b488a3 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify URL을 붙여 넣거나 검색", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "{extensionName}에서 검색", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify URL을 붙여 넣거나 검색", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "기록", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "다운로드 중... {count}", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "다운로드 목록", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural,=1{1 track}other{{count}tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural,=1{1 album}other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "모드 선택", - "setupModeSelectionDescription": "SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.", - "setupModeDownloaderTitle": "다운로더", - "setupModeDownloaderFeature1": "무손실 FLAC 품질로 트랙 다운로드", - "setupModeDownloaderFeature2": "오프라인 감상을 위해 기기에 음악 저장", - "setupModeDownloaderFeature3": "로컬 음악 라이브러리 관리", - "setupModeStreamingTitle": "스트리밍", - "setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍", - "setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다", - "setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생", - "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index ebac52c4..1fd0bec6 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Kies je modus", - "setupModeSelectionDescription": "Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.", - "setupModeDownloaderTitle": "Downloader", - "setupModeDownloaderFeature1": "Download nummers in lossless FLAC-kwaliteit", - "setupModeDownloaderFeature2": "Sla muziek op je apparaat op om offline te luisteren", - "setupModeDownloaderFeature3": "Beheer je lokale muziekbibliotheek", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Stream nummers direct zonder te downloaden", - "setupModeStreamingFeature2": "Smart Queue ontdekt automatisch nieuwe muziek voor je", - "setupModeStreamingFeature3": "Speel elk nummer op aanvraag af met afspeelbediening", - "setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 9c7f026d..503247dd 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -5,18 +5,10 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -29,20 +21,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -55,24 +33,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -85,48 +45,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "settingsTitle": "Settings", "@settingsTitle": { "description": "Settings screen title" @@ -155,34 +73,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -195,38 +85,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -247,10 +109,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -267,10 +125,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -426,22 +280,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -468,10 +306,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -544,10 +378,6 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -564,14 +394,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -584,35 +406,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -625,43 +418,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -678,54 +434,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -734,10 +446,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -769,10 +477,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -817,26 +521,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -857,14 +541,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -873,58 +549,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -933,10 +565,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -945,26 +573,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -977,26 +593,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1025,34 +625,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1173,15 +749,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1242,16 +809,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1265,34 +822,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1305,14 +834,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1321,14 +842,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1350,19 +863,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1403,55 +903,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1492,27 +947,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1553,14 +991,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1581,14 +1011,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1613,22 +1035,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1661,22 +1067,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1689,60 +1079,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1849,22 +1185,6 @@ "@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" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -1893,10 +1213,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2019,15 +1335,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2063,22 +1370,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2107,18 +1398,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2338,14 +1617,6 @@ "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2354,66 +1625,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2422,18 +1633,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2442,38 +1641,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2519,19 +1686,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2562,19 +1716,13 @@ "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, - "setupModeSelectionTitle": "Escolha seu modo", - "setupModeSelectionDescription": "Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.", - "setupModeDownloaderTitle": "Downloader", - "setupModeDownloaderFeature1": "Baixe faixas em qualidade FLAC lossless", - "setupModeDownloaderFeature2": "Salve músicas no seu dispositivo para ouvir offline", - "setupModeDownloaderFeature3": "Gerencie sua biblioteca de músicas local", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem baixar", - "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para você", - "setupModeStreamingFeature3": "Reproduza qualquer faixa sob demanda com controles de reprodução", - "setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + } +} diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 57592cd5..8d844396 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Início", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Histórico", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Configurações", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Pesquise ou cole a URL do Spotify...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Pesquisar com {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Cole um link do Spotify ou procure por nome", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Histórico", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Baixando ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Baixados", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Tudo", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {1 faixa} other{{count} faixas}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {1 álbum} other{{count} álbuns}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Nenhum histórico de downloads", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "As faixas baixadas aparecerão aqui", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Sem álbuns baixados", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Baixe várias faixas de um álbum para vê-las aqui", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Sem singles baixados", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Os downloads de faixa individuais aparecerão aqui", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Pesquisar histórico...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Local dos Downloads", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Escolha onde salvar os arquivos", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Local padrão", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Serviço Padrão", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Serviço usado para downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Qualidade Predefinida", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Perguntar qualidade antes de baixar", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Mostrar seletor de qualidade para cada download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separar Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Colocar singles numa pasta separada", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Melhor Disponível", - "@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": "Aparência", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistema", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Cor de Destaque", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Visualização do Histórico", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Origem da Pesquisa", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Provedor Primário", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Extensões Instaladas", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Nenhuma extensão instalada", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Instalar extensões a partir da aba Loja", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Habilitado", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Desabilitado", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Definir como Provedor de Pesquisa", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Loja de Extensões", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Apoiar", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Aplicativo", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Álbum", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {1 faixa} other{{count} faixas}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Baixar Tudo", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Downloads Restantes", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artista", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Álbuns", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {1 lançamento} other{{count} lançamentos}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Populares", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Informações da Faixa", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artista", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Álbum", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duração", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Qualidade", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Caminho do Arquivo", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Baixado", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Serviço", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Baixar Novamente", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Abrir Pasta", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Bem-vindo ao SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Vamos começar", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Permissão de Armazenamento", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Necessária para salvar arquivos baixados", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permissão concedida", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permissão negada", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Conceder Permissão", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Local do Download", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Selecionar Pasta", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continuar", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Ignorar por enquanto", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Escolher Pasta de Download", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Usar Pasta Padrão?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Armazenamento", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notificação", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Pasta", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permissão", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Permissão de Armazenamento Concedida!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Seja notificado quando os downloads completarem ou exigirem atenção.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Pasta para Download Selecionada!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Escolher Pasta de Download", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Alterar Pasta", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Seleccionar Pasta", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "API do Spotify (opcional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Usar API do Spotify", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Insira as suas credenciais abaixo", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Usando o Deezer (nenhuma conta necessária)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Insira o Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Insira o Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Habilitar Notificações", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Você já pode prosseguir para o próximo passo.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Você receberá notificações de progresso dos downloads.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Voltar", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Próximo", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Ignorar e Iniciar", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Obter credenciais do developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancelar", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Salvar", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Fechar", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Sim", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Não", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Limpar", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirmar", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Concluído", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Falhou", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Faixa:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artista:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Erro:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Limpar Tudo", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Você tem certeza que deseja limpar todos os downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remover do dispositivo?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remover Extensão", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Falha ao carregar: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "URL do {platform} copiado para a área de transferência", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Falha ao carregar {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Nenhuma faixa encontrada", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Na Fila", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Baixando", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizando", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Concluído", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Falhou", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Ignorado", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Pausado", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pausar", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Parar", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Selecionar", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Selecionar Tudo", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Colar", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Importar CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remover Credenciais", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Toque nas faixas para selecionar", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Apagar {count} {count, plural, one {faixa} other{faixas}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Selecione as faixas para apagar", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancelar", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Parar", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Tentar Novamente", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remover", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Limpar", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Colar", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Formato do Nome do Arquivo", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Prévia: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Substituições permitidas:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Organização de Pastas", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Nenhuma organização", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "A versão {version} está disponível", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Baixar", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Depois", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Lista de alterações", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Iniciando download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Prioridade de Provedor", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Arraste para reordenar os provedores de download", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Prioridade de Provedor", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Prioridade de Provedor de Metadados", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Ordem usada para obter metadados de faixa", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Prioridade de Metadados", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copiar Registros", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Limpar Registros", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Compartilhar Registros", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Ainda não há registros", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Registros copiados para área de transferência", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "BLOQUEIO DE ISP DETECTADO", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "TAXA LIMITADA (RATELIMITED)", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ERRO DE REDE", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "FAIXA NÃO ENCONTRADA", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filtrar registros por gravidade", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Resumo do Problemas", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "O seu provedor pode estar bloqueando o acesso aos serviços de download", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Muitas solicitações ao serviço", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Aguarde alguns minutos antes de tentar novamente", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Problemas de conexão detectados", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Verifique sua conexão de internet", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Algumas faixas não foram encontradas nos serviços de download", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "A faixa pode não estar disponível em qualidade sem perdas", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total de erros: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Afetado(s): {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entradas ({count} filtradas)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Escolha o seu idioma preferido", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Tema, cores, exibição", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Faixas", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Baixar Todos ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Não foi possível abrir: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Hoje", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequencial", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Paralelos", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Paralelos", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Toque para ver os detalhes do erro", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Tudo", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "Nenhuma extensão encontrada", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Prioridade de Provedor", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Instalar Extensão", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Padrão (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "A qualidade real depende da faixa que estiver disponível no serviço", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Formato para Salvar", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Selecionar Serviço", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Selecionar Qualidade", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Qualidade Padrão", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Melhor Disponível", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Nenhuma", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Salvar todos os arquivos diretamente na pasta de download", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artista", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Nome do Artista/arquivo", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Álbum", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Nome do Álbum/arquivo", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artista/Álbum", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Nome do Artista/Nome do Álbum/arquivo", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "Escuro AMOLED", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Escolha a Cor de Destaque", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Modo do Tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Fila de Download", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Limpar Tudo", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "Nenhum download na fila", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Adicione faixas a partir da tela inicial", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Limpar concluídos", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Falhou", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Faixa:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artista:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Erro:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Erro desconhecido", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artista / Álbum", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Faixas", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} baixado(s)", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selecionado(s)", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Funções Utilitárias", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artista", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Erro: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Baixar Discografia", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Escolha o seu modo", - "setupModeSelectionDescription": "Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.", - "setupModeDownloaderTitle": "Transferência", - "setupModeDownloaderFeature1": "Transfira faixas em qualidade FLAC sem perdas", - "setupModeDownloaderFeature2": "Guarde música no seu dispositivo para ouvir offline", - "setupModeDownloaderFeature3": "Faça a gestão da sua biblioteca de música local", - "setupModeStreamingTitle": "Streaming", - "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem transferir", - "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para si", - "setupModeStreamingFeature3": "Reproduza qualquer faixa a pedido com controlos de reprodução", - "setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} baixado(s)", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index f8f9d028..4ea4e618 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Главная", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "История", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Настройки", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Вставьте URL Spotify или выполните поиск...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Искать с помощью {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Вставьте ссылку Spotify или ищите по названию", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "История", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Скачивание ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Скачано", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Все", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Нет истории скачиваний", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Скачанные треки появятся здесь", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Нет скачанных альбомов", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Скачайте несколько треков из альбома, чтобы увидеть их здесь", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Нет скачанных синглов", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Здесь будут отображаться загрузки синглов", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Поиск в истории...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Папка для скачивания", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Выберите, куда сохранить файлы", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Расположение по умолчанию", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Сервис по умолчанию", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Сервис, используемый для скачивания", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Качество по умолчанию", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Спрашивать качество перед скачиванием", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Показывать выбор качества для каждого скачивания", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Разделять синглы", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Помещать синглы в отдельную папку", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Лучшее из доступных", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 кбит/с", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 кбит/с", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Внешний вид", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Тема", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Системная", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Акцентный цвет", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Отображение истории", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Поиск источника", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Основной провайдер", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Установленные расширения", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Нет установленных расширений", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Установка расширений из вкладки Магазин", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Включено", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Выключено", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Установить в качестве поисковой системы", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Магазин расширений", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Поддержка", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Приложение", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Удивительный API для загрузок Amazon Music. Спасибо за то, что сделали это бесплатно!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Альбом", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Скачать всё", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Скачать оставшиеся", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Плейлист", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Исполнитель", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Альбомы", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Популярное", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Информация о треке", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Исполнитель", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Альбом", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Продолжительность", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Качество", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Путь к файлу", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Скачано", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Сервис", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Скачать снова", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Открыть папку", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Добро пожаловать в SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Давайте начнем", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Доступ к хранилищу", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Необходимо для сохранения загруженных файлов", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Разрешение предоставлено", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Разрешение не предоставлено", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Предоставить разрешение", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Папка для скачивания", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Выбрать папку", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Продолжить", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Пропустить", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC требуется разрешение \"Доступ ко всем файлам\" для сохранения музыкальных файлов в выбранную папку.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Для Android 11+ требуется разрешение \"Доступ ко всем файлам\" для сохранения файлов в выбранную вами папку загрузки.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Выбрать папку для скачивания", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Использовать папку по умолчанию?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Хранилище", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Уведомления", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Папка", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Разрешение", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Доступ к хранилищу предоставлен!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Получайте уведомления о завершении загрузки или о необходимости привлечения внимания.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Папка для загрузки выбрана!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Выбрать папку для скачивания", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Сменить папку", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Выбрать папку", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (необязательно)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Добавьте свои учётные данные Spotify для улучшения результатов поиска и доступа к эксклюзивному контенту Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Использовать Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Введите ваши учётные данные ниже", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Использование Deezer (аккаунт не требуется)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Введите Client ID Spotify", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Введите Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Получите бесплатный API учётной записи на панели разработчика Spotify.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Включить уведомления", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Теперь вы можете перейти к следующему шагу.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Вы будете получать уведомления о ходе загрузки.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Получайте уведомления о ходе и завершении загрузки. Это поможет вам отслеживать загрузки, когда приложение находится в фоновом режиме.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Назад", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Далее", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Пропустить и начать", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Получить учётные данные с developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Отмена", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "ОК", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Сохранить", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Закрыть", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Да", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Нет", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Очистить", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Подтвердить", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Готово", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Ошибка скачивания", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Трек:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Исполнитель:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Ошибка:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Очистить всё", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Вы уверены, что хотите очистить все загрузки?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Удалить с устройства?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Удалить расширение", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Ошибка загрузки: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} ссылка скопирована в буфер обмена", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Ошибка загрузки {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Треки не найдены", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "В очереди", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Скачивание", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Завершение", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Завершено", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Неудачно", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Пропущено", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Приостановлено", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Пауза", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Стоп", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Выбрать", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Выбрать все", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Вставить", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Импорт CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Удалить учётные данные", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Нажмите на треки для выбора", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Выберите треки для удаления", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Отмена", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Стоп", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Повторить", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Убрать", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Очистить", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Вставить", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Формат имени файла", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Предпросмотр: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Доступные заполнители:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Организация папок", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Без организации", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Версия {version} доступна", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Скачать", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Позже", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Список изменений", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Загрузка началась...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Приоритет провайдера", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Перетащите для изменения порядка", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Приоритет провайдера", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Приоритет провайдера метаданных", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Порядок, используемый при получении метаданных", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Приоритет метаданных", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Скопировать логи", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Очистить логи", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Поделиться логами", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Логов нет", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Логи скопированы в буфер обмена", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ОБНАРУЖЕНА БЛОКИРОВКА ИНТЕРНЕТ ПРОВАЙДЕРОМ", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "ОГРАНИЧЕННАЯ СКОРОСТЬ", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ОШИБКА СЕТИ", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "ТРЕК НЕ НАЙДЕН", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Фильтровать логи по серьезности", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Краткое описание проблемы", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Ваш провайдер может блокировать доступ к сервисам скачивания", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Попробуйте использовать VPN или измените DNS на 1.1.1.1 или 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Слишком много запросов к сервису", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Подождите несколько минут, прежде чем повторить попытку", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Обнаружены проблемы с подключением", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Проверьте подключение к Интернету", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Некоторые треки не найдены в сервисах загрузки", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Трек может быть недоступен в lossless формате", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Всего ошибок: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Затронуто: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Записи ({count} фильтровано)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Выберите предпочитаемый язык", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Тема, цвета, дисплей", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Треки", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Скачать все ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Невозможно открыть: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Сегодня", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Последовательно", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 параллельно", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 параллельно", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Нажмите, чтобы увидеть подробности ошибки", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Все", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "Расширения не найдены", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Приоритет провайдера", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Установить расширение", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "По умолчанию (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "Opus 320 кбит/с (конвертировать из FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128 кбит/с (конвертировать из FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Включить опцию Lossy", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Доступно качество с потерями", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Скачивать FLAC и конвертировать в MP3 320 кбит/с", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Формат с потерями", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Выберите Lossy формат для конвертации", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320Кбит/с, лучшая совместимость", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128кбит/с, лучшее качество при меньших размерах", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Фактическое качество зависит от доступности треков в сервисе", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Папки исполнителя используют только трек исполнителя", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Формат сохранения", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Выбор сервиса", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Выбор качества", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Качество по умолчанию", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Лучшее из доступных", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Отсутствует", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Сохранить все файлы непосредственно в папку загрузки", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Исполнитель", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Исполнитель/имя файла", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Альбом", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Альбом/имя файла", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Исполнитель/Альбом", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Исполнитель/ Альбом/имя файла", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Выберите акцентный цвет", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Режим темы", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Очередь скачиваний", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Очистить всё", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Экспорт", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Сбой при экспорте загрузок в файл TXT", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Не удалось очистить", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Не удалось экспортировать загрузки", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Автоэкспорт неудачных загрузок", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "Нет загрузок в очереди", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Добавить треки с главного экрана", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Очистка завершена", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Ошибка скачивания", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Трек:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Исполнитель:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Ошибка:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Неизвестная ошибка", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Исполнитель / Альбом", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Треки", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} скачано", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} выбрано", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Функции утилиты", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Исполнитель", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Ошибка: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Скачать дискографию", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Статус Библиотеки", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Настройки сканирования", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Последнее сканирование: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Дата добавления", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Сегодня", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "На этой неделе", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "В этом месяце", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "В этом году", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Сортировка", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} фильтр(-ов) активно", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Только что", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Сменить режим хранения", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Переключиться на SAF хранилище?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Переключиться хранилище приложения?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Существующие загрузки", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в {mode} хранилище", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "Новые загрузки", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Будет сохранено в: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Продолжить", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Выберите папку SAF", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "Хранилище приложения", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "Хранилище SAF", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Хранилище: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Статистика хранилища", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в хранилище приложения", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в вашей папке в SAF", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Ваши файлы хранятся в нескольких местах", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Добро пожаловать в SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Вставьте ссылку Spotify или Deezer прямо в поле поиска", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Или введите название песни, исполнителя или альбом для поиска", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Поддержка треков, альбомов, плейлистов и страниц исполнителей", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Скачивание музыки", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Скачать все альбомы или плейлисты одним нажатием", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Ваша библиотека", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Полное сканирование", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Выберите режим", - "setupModeSelectionDescription": "Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.", - "setupModeDownloaderTitle": "Загрузчик", - "setupModeDownloaderFeature1": "Скачивайте треки в качестве FLAC без потерь", - "setupModeDownloaderFeature2": "Сохраняйте музыку на устройство для прослушивания офлайн", - "setupModeDownloaderFeature3": "Управляйте своей локальной музыкальной библиотекой", - "setupModeStreamingTitle": "Стриминг", - "setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания", - "setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас", - "setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления", - "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} скачано", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Папки исполнителя используют только трек исполнителя", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index ef9184f9..b7b16c7f 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Ara", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Geçmiş", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Ayarlar", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify URL'i yapıştır veya ara...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "{extensionName} ile arat...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify linki yapıştır veya isimle arat", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Geçmiş", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "({count}) tane indiriliyor", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "İndirildi", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Tümü", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {1 şarkı} other {{count} şarkı}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {1 albüm} other {{count} albüm}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "İndirme geçmişi yok", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "İndirilen şarkılar burada gözükecek", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "İndirilen albüm yok", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Albümleri burada görmek için bir albümden birden fazla şarkı indir", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Single indirilmemiş", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single şarkılar burada gözükecek", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Arama geçmişi...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "İndirme Konumu", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Dosyaları nereye kaydedeceğinizi seçin", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Varsayılan dizin", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Varsayılan Hizmet", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "İndirmeler için kullanılan hizmet", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Varsayılan Kalite", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "İndirmeden Önce Kaliteyi Sor", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Her indirmeden önce kalite seçim ekranını göster", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Single'ları Ayır", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Single şarkıları ayrı dosyaya koy", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Mevcut en iyi", - "@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": "Görünüm", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistem", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Vurgu Rengi", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Geçmiş Düzeni", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Arama Kaynağı", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Ana Kaynek", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Kurulu Eklentiler", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Hiçbir eklenti kurulmamış", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Dükkan sekmesinden eklenti indir", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Etkin", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Devre Dışı", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Arama Sağlayıcı olarak Ayarla", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Eklenti Dükkanı", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Destek", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Uygulama", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazom Music indirmeleri için harika bir API. Ücretsiz yaptığın için teşekkürler!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Albüm", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {1 şarkı} other {{count} şarkı}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Tümünü İndir", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Kalanını İndir", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Çalma Listesi", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Sanatçı", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albümler", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 yayın} other{{count} yayın}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popüler", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Şarkı Bilgisi", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Sanatçı", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Albüm", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Süre", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Kalite", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Dosya Yolu", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "İndirme tarihi", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Hizmet", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Yeniden İndir", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Klasörü Aç", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "SpotiFLAC'e Hoşgeldiniz", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Hadi başlayalım", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Depolama İzni", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "İndirilen dosyaları kaydetmek için gerekli", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "İzin verildi", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "İzin reddedildi", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "İzin Ver", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "İndirme Konumu", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Klasör Seç", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Devam", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Şimdilik atla", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC'ın şarkıları seçili klasörünüze kaydetmek için \"Bütün dosyalara eriş\" iznine ihtiyacı var.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11 ve sonrasında şarkıların seçili klasörünüze kaydedilebilmesi için \"Bütün dosyalara eriş\" iznine ihtiyaç var.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "İndirilecek Klasörü Seç", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Varsayılan Klasörü Kullan?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Depolama", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Bildirim", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Klasör", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "İzin", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Depolama İzni Verildi!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "İndirmeler bittiğinde veya bakılması gereken bir şey olduğunda haberdar olun.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "İndirilecek Klasör Seçildi!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "İndirilecek Klasörü Seç", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Klasörü Değiştir", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Klasör Seç", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (İsteğe Bağlı)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Daha iyi arama sonuçları ve Spotify'a özel içeriklere erişmek için Spotify API kimlik bilgilerini gir.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Spotify API'ı kullan", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Kimlik bilgilerini aşağıya gir", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Deezer kullanılıyor (hesap gerekli değil)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Spotify Client ID gir", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Spotify Client Secret gir", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Spotify Developer Dashboard'tan API kimlik bilgilerini ücretsiz alın.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Bildirimleri Etkinleştir", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Bir sonraki adıma geçebilirsin.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "İndirme ilerlemelerinin bildirimlerini alacaksın.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "İndirmelerin durumu hakkında bildirim al. Bunu açmak uygulama arka plandayken indirmelerinizi takip etmenizi sağlar.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Geri", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Sıradaki", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Kurulumu atla", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Lütfen bir sonraki ekranda \"Bütün dosyalara eriş\" iznini sağlayın.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Kimlik bilgilerini developer.spotify.com'dan alın", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "İptal", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "Tamam", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Kaydet", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Kapat", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Evet", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Hayır", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Temizle", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Onayla", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Tamamlandı", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "İndirme Başarısız", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Şarkı:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Sanatçı:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Hata:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Tümünü Temizle", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Bütün indirmeleri temizlemek istediğinize emin misiniz?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Cihazdan kaldır?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Eklentiyi Kaldır", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Yüklenemedi: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} Bağlantı panoya kopyalandı", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "{item} yüklenirken hata oluştu", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Parça bulunamadı", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Sıraya alındı", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "İndiriliyor", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Tamamlanıyor", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Tamamlandı", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Başarısız", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Atlandı", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Durduruldu", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Duraklat", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Durdur", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Seç", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Tümünü Seç", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Yapıştır", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "CSV İçe Aktar", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Özellikleri kaldır", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Seçmek için parçalara dokunun", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "{count} {count, plural, =1{şarkıyı} other{şarkıyı}} sil", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Silinecek parçaları seçin", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Vazgeç", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Durdur", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Yeniden dene", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Kaldır", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Temizle", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Yapıştır", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Dosya adı formatı", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Önizleme: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Kullanılabilir yer tutucular:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Klasör Organizasyonu", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Organizasyon yok", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "{version} sürümü mevcut", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "İndir", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Daha Sonra", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Değişiklikler", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "İndirme başlıyor...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "İndirme hizmetleri öncelik sırası", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "İndirme hizmetlerini sıralamak için kaydır", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "İndirme hizmetleri öncelik sırası", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Sağlayıcı Önceliği", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Şarkı metadata'sı alınırken kullanılan sıra", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Önceliği", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Kayıtları Kopyala", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Kayıtları temizle", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Kayıtları Paylaş", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Henüz kayıt yok", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Kayıtlar panoya kopyalandı", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Kayıtları önem derecesine göre filtrele", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Sorun Özeti", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "İnternet sağlayıcınız indirme hizmetlerine erişimi engelliyor olabilir", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "VPN kullanmayı veya DNS'i 1.1.1.1 ya da 8.8.8.8 olarak değiştirmeyi deneyin", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Hizmete çok fazla istek gönderildi", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Tekrar denemeden önce birkaç dakika bekleyin", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Bağlantı sorunları tespit edildi", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "İnternet bağlantınızı kontrol edin", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Bazı şarkılar indirme hizmetlerinde bulunamadı", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Şarkı kayıpsız kalitede mevcut olmayabilir", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Tercih ettiğiniz dili seçin", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Tema, renkler, görünüm", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Şarkılar", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Tümünü İndir ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Bugün", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sıralı", - "@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": "Hata detaylarını görmek için dokun", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Tümü", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "Eklenti bulunamadı", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Sağlayıcı Önceliği", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Eklenti Yükle", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Varsayılan (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "Modunuzu Seçin", - "setupModeSelectionDescription": "SpotiFLAC'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar'dan değiştirebilirsiniz.", - "setupModeDownloaderTitle": "İndirici", - "setupModeDownloaderFeature1": "Kayıpsız FLAC kalitesinde parça indirin", - "setupModeDownloaderFeature2": "Çevrimdışı dinlemek için müziği cihazınıza kaydedin", - "setupModeDownloaderFeature3": "Yerel müzik kütüphanenizi yönetin", - "setupModeStreamingTitle": "Yayın Akışı", - "setupModeStreamingFeature1": "İndirmeden parçaları anında yayınlayın", - "setupModeStreamingFeature2": "Smart Queue sizin için otomatik olarak yeni müzik keşfeder", - "setupModeStreamingFeature3": "İstediğiniz parçayı oynatma kontrolleriyle çalın", - "setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz." -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 0209b3a6..f6f4895f 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -5,18 +5,10 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -29,20 +21,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -55,24 +33,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -85,48 +45,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "settingsTitle": "Settings", "@settingsTitle": { "description": "Settings screen title" @@ -155,34 +73,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -195,38 +85,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -247,10 +109,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -267,10 +125,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -426,22 +280,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -468,10 +306,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -544,10 +378,6 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -564,14 +394,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -584,35 +406,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -625,43 +418,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -678,54 +434,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -734,10 +446,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -769,10 +477,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -817,26 +521,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -857,14 +541,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -873,58 +549,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -933,10 +565,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -945,26 +573,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -977,26 +593,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1025,34 +625,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1173,15 +749,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1242,16 +809,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1265,34 +822,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1305,14 +834,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1321,14 +842,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1350,19 +863,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1403,55 +903,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1492,27 +947,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1553,14 +991,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1581,14 +1011,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1613,22 +1035,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1661,22 +1067,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1689,60 +1079,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1849,22 +1185,6 @@ "@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" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -1893,10 +1213,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2019,15 +1335,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2063,22 +1370,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2107,18 +1398,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2338,14 +1617,6 @@ "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2354,66 +1625,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2422,18 +1633,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2442,38 +1641,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2519,19 +1686,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2562,19 +1716,13 @@ "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, - "setupModeSelectionTitle": "选择您的模式", - "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。", - "setupModeDownloaderTitle": "下载器", - "setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目", - "setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听", - "setupModeDownloaderFeature3": "管理您的本地音乐库", - "setupModeStreamingTitle": "流媒体", - "setupModeStreamingFeature1": "无需下载即可即时播放曲目", - "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", - "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", - "setupModeChangeableLater": "您可以随时在设置中切换模式。" -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + } +} diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index b26eb35e..67d8b58c 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "选择您的模式", - "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。", - "setupModeDownloaderTitle": "下载器", - "setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目", - "setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听", - "setupModeDownloaderFeature3": "管理您的本地音乐库", - "setupModeStreamingTitle": "流媒体", - "setupModeStreamingFeature1": "无需下载即可即时播放曲目", - "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", - "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", - "setupModeChangeableLater": "您可以随时在设置中切换模式。" -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 51935549..d230aa50 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3754,10 +2672,6 @@ "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3869,15 +2783,21 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "setupModeSelectionTitle": "選擇您的模式", - "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。", - "setupModeDownloaderTitle": "下載器", - "setupModeDownloaderFeature1": "以無損 FLAC 品質下載曲目", - "setupModeDownloaderFeature2": "將音樂儲存到裝置以供離線收聽", - "setupModeDownloaderFeature3": "管理您的本機音樂庫", - "setupModeStreamingTitle": "串流", - "setupModeStreamingFeature1": "無需下載即可即時串流曲目", - "setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂", - "setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目", - "setupModeChangeableLater": "您可以隨時在設定中切換模式。" -} \ No newline at end of file + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9f61f4e2..4f2059db 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -625,6 +625,7 @@ final downloadHistoryProvider = ); class DownloadQueueState { + static const Object _noChange = Object(); final List items; final DownloadItem? currentDownload; final bool isProcessing; @@ -649,7 +650,7 @@ class DownloadQueueState { DownloadQueueState copyWith({ List? items, - DownloadItem? currentDownload, + Object? currentDownload = _noChange, bool? isProcessing, bool? isPaused, String? outputDir, @@ -660,7 +661,9 @@ class DownloadQueueState { }) { return DownloadQueueState( items: items ?? this.items, - currentDownload: currentDownload ?? this.currentDownload, + currentDownload: identical(currentDownload, _noChange) + ? this.currentDownload + : currentDownload as DownloadItem?, isProcessing: isProcessing ?? this.isProcessing, isPaused: isPaused ?? this.isPaused, outputDir: outputDir ?? this.outputDir, @@ -717,6 +720,7 @@ class DownloadQueueNotifier extends Notifier { int _totalQueuedAtStart = 0; int _completedInSession = 0; int _failedInSession = 0; + int _queueItemSequence = 0; bool _isLoaded = false; final Set _ensuredDirs = {}; int _progressPollingErrorCount = 0; @@ -735,6 +739,7 @@ class DownloadQueueNotifier extends Notifier { String? _lastNotifArtistName; int _lastNotifPercent = -1; int _lastNotifQueueCount = -1; + final Set _locallyCancelledItemIds = {}; double _normalizeProgressForUi(double value) { final clamped = value.clamp(0.0, 1.0).toDouble(); @@ -854,8 +859,11 @@ class DownloadQueueNotifier extends Notifier { return; } - state = state.copyWith(items: pendingItems); - _log.i('Restored ${pendingItems.length} pending items from storage'); + final normalizedPendingItems = _normalizeRestoredQueueIds(pendingItems); + state = state.copyWith(items: normalizedPendingItems); + _log.i( + 'Restored ${normalizedPendingItems.length} pending items from storage', + ); Future.microtask(() => _processQueue()); } catch (e) { _log.e('Failed to load queue from storage: $e'); @@ -1644,6 +1652,53 @@ class DownloadQueueNotifier extends Notifier { return _isrcRegex.hasMatch(value.toUpperCase()); } + String _newQueueItemId(Track track, {Set? takenIds}) { + final trimmedIsrc = track.isrc?.trim(); + final trimmedTrackId = track.id.trim(); + final base = (trimmedIsrc != null && trimmedIsrc.isNotEmpty) + ? trimmedIsrc + : (trimmedTrackId.isNotEmpty ? trimmedTrackId : 'track'); + + while (true) { + _queueItemSequence++; + final candidate = + '$base-${DateTime.now().microsecondsSinceEpoch}-$_queueItemSequence'; + if (takenIds == null || !takenIds.contains(candidate)) { + return candidate; + } + } + } + + List _normalizeRestoredQueueIds(List items) { + if (items.isEmpty) return items; + + final seen = {}; + var regeneratedCount = 0; + final normalized = []; + + for (final item in items) { + final trimmedId = item.id.trim(); + final shouldRegenerate = trimmedId.isEmpty || seen.contains(trimmedId); + if (shouldRegenerate) { + final newId = _newQueueItemId(item.track, takenIds: seen); + seen.add(newId); + normalized.add(item.copyWith(id: newId)); + regeneratedCount++; + } else { + seen.add(trimmedId); + normalized.add(item); + } + } + + if (regeneratedCount > 0) { + _log.w( + 'Regenerated $regeneratedCount duplicate/empty queue item IDs during restore', + ); + } + + return normalized; + } + void updateSettings(AppSettings settings) { final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); state = state.copyWith( @@ -1661,8 +1716,8 @@ class DownloadQueueNotifier extends Notifier { final settings = ref.read(settingsProvider); updateSettings(settings); - final id = - '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + final takenIds = state.items.map((item) => item.id).toSet(); + final id = _newQueueItemId(track, takenIds: takenIds); final item = DownloadItem( id: id, track: track, @@ -1689,9 +1744,10 @@ class DownloadQueueNotifier extends Notifier { final settings = ref.read(settingsProvider); updateSettings(settings); + final takenIds = state.items.map((item) => item.id).toSet(); final newItems = tracks.map((track) { - final id = - '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + final id = _newQueueItemId(track, takenIds: takenIds); + takenIds.add(id); return DownloadItem( id: id, track: track, @@ -1770,12 +1826,30 @@ class DownloadQueueNotifier extends Notifier { ); } - void cancelItem(String id) { - updateItemStatus(id, DownloadStatus.skipped); + DownloadItem? _findItemById(String id) { + for (final item in state.items) { + if (item.id == id) return item; + } + return null; + } + + bool _isLocallyCancelled(String id, {DownloadItem? item}) { + if (_locallyCancelledItemIds.contains(id)) return true; + final resolved = item ?? _findItemById(id); + return resolved?.status == DownloadStatus.skipped; + } + + void _requestNativeCancel(String id) { PlatformBridge.cancelDownload(id).catchError((_) {}); PlatformBridge.clearItemProgress(id).catchError((_) {}); } + void cancelItem(String id) { + _locallyCancelledItemIds.add(id); + updateItemStatus(id, DownloadStatus.skipped); + _requestNativeCancel(id); + } + void clearCompleted() { final items = state.items .where( @@ -1791,8 +1865,30 @@ class DownloadQueueNotifier extends Notifier { } void clearAll() { - state = state.copyWith(items: [], isPaused: false); + final wasProcessing = state.isProcessing; + final activeIds = state.items + .where( + (item) => + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ) + .map((item) => item.id) + .toList(growable: false); + + if (activeIds.isNotEmpty) { + _locallyCancelledItemIds.addAll(activeIds); + for (final id in activeIds) { + _requestNativeCancel(id); + } + } + + state = state.copyWith(items: [], isPaused: false, currentDownload: null); + _notificationService.cancelDownloadNotification(); _saveQueueToStorage(); + if (!wasProcessing) { + _locallyCancelledItemIds.clear(); + } } void pauseQueue() { @@ -1835,6 +1931,7 @@ class DownloadQueueNotifier extends Notifier { } _log.i('Retrying item: ${item.track.name} (id: $id)'); + _locallyCancelledItemIds.remove(id); final items = state.items.map((i) { if (i.id == id) { @@ -1858,6 +1955,7 @@ class DownloadQueueNotifier extends Notifier { } void removeItem(String id) { + _locallyCancelledItemIds.remove(id); final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); _saveQueueToStorage(); @@ -2892,17 +2990,16 @@ class DownloadQueueNotifier extends Notifier { } _stopProgressPolling(); + final remainingIds = state.items.map((item) => item.id).toSet(); + _locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id)); } Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); - final currentItem = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (currentItem.status == DownloadStatus.skipped) { + final currentItem = _findItemById(item.id) ?? item; + if (_isLocallyCancelled(item.id, item: currentItem)) { _log.i('Download was cancelled before start, skipping'); return; } @@ -3315,6 +3412,11 @@ class DownloadQueueNotifier extends Notifier { ); } + if (_isLocallyCancelled(item.id)) { + _log.i('Download was cancelled before native download start, skipping'); + return; + } + result = await runDownload( useSaf: effectiveSafMode, outputDir: effectiveOutputDir, @@ -3323,6 +3425,10 @@ class DownloadQueueNotifier extends Notifier { if (effectiveSafMode && result['success'] != true && _isSafWriteFailure(result)) { + if (_isLocallyCancelled(item.id)) { + _log.i('Download was cancelled before SAF fallback, skipping'); + return; + } _log.w('SAF write failed, retrying with app-private storage'); appOutputDir ??= await _buildOutputDir( trackToDownload, @@ -3348,11 +3454,11 @@ class DownloadQueueNotifier extends Notifier { _log.d('Result: $result'); - final currentItem = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (currentItem.status == DownloadStatus.skipped) { + final itemAfterResult = _findItemById(item.id); + final cancelledAfterResult = + itemAfterResult == null || + _isLocallyCancelled(item.id, item: itemAfterResult); + if (cancelledAfterResult) { _log.i('Download was cancelled, skipping result processing'); final filePath = result['file_path'] as String?; if (filePath != null && result['success'] == true) { @@ -4083,11 +4189,9 @@ class DownloadQueueNotifier extends Notifier { } } - final itemAfterDownload = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (itemAfterDownload.status == DownloadStatus.skipped) { + final itemAfterDownload = _findItemById(item.id); + if (itemAfterDownload == null || + _isLocallyCancelled(item.id, item: itemAfterDownload)) { _log.i('Download was cancelled during finalization, cleaning up'); if (filePath != null) { await deleteFile(filePath); @@ -4309,11 +4413,9 @@ class DownloadQueueNotifier extends Notifier { removeItem(item.id); } } else { - final itemAfterFailure = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (itemAfterFailure.status == DownloadStatus.skipped) { + final itemAfterFailure = _findItemById(item.id); + if (itemAfterFailure == null || + _isLocallyCancelled(item.id, item: itemAfterFailure)) { _log.i('Download was cancelled, skipping error handling'); return; } @@ -4374,11 +4476,9 @@ class DownloadQueueNotifier extends Notifier { } } } catch (e, stackTrace) { - final itemAfterError = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (itemAfterError.status == DownloadStatus.skipped) { + final itemAfterError = _findItemById(item.id); + if (itemAfterError == null || + _isLocallyCancelled(item.id, item: itemAfterError)) { _log.i('Download was cancelled, skipping error handling'); return; } From f306599ab2c44f26d24d0d9d739146ced476fc18 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 6 Mar 2026 16:37:01 +0700 Subject: [PATCH 35/38] v3.7.1: YT Music extension priority for YouTube downloads, Qobuz store fallback, queue fixes, server-side search filters --- CHANGELOG.md | 21 +++ go_backend/qobuz.go | 239 ++++++++++++++++++++----------- go_backend/songlink.go | 62 ++++++++ go_backend/youtube.go | 69 ++++++++- lib/constants/app_info.dart | 4 +- lib/services/update_checker.dart | 15 -- pubspec.yaml | 2 +- 7 files changed, 310 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b7c758b..ecf8a8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [3.7.1] - 2026-03-06 + +### Added + +- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases. +- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in. +- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track. +- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary. + +### Fixed + +- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads. +- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts. + +### Changed + +- **Update Checker**: The app can now detect updates across all versions, not just within the same major version. +- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages. + +--- + ## [3.7.0] - 2026-03-04 Hey everyone, thank you so much for sticking with SpotiFLAC Mobile. diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 52653172..015dca8d 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -12,6 +12,8 @@ import ( "net/url" "os" "path/filepath" + "regexp" + "strconv" "strings" "sync" "time" @@ -31,13 +33,17 @@ var ( const ( qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" + qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" qobuzDebugKeyXORMask = byte(0x5A) ) +var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) + var qobuzDebugKeyObfuscated = []byte{ 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, 0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b, @@ -403,6 +409,7 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, // "deeb" is mapped from the legacy reference fallback endpoint. {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, } } @@ -560,39 +567,18 @@ func getQobuzDebugKey() string { } func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) + candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) if err != nil { return nil, err } - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - for i := range result.Tracks.Items { - if result.Tracks.Items[i].ISRC == isrc { - return &result.Tracks.Items[i], nil + for i := range candidates { + if candidates[i].ISRC == isrc { + return &candidates[i], nil } } - if len(result.Tracks.Items) == 0 { + if len(candidates) == 0 { return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) } @@ -602,38 +588,17 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) + candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) if err != nil { return nil, err } - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) + GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates)) var isrcMatches []*QobuzTrack - for i := range result.Tracks.Items { - if result.Tracks.Items[i].ISRC == isrc { - isrcMatches = append(isrcMatches, &result.Tracks.Items[i]) + for i := range candidates { + if candidates[i].ISRC == isrc { + isrcMatches = append(isrcMatches, &candidates[i]) } } @@ -668,7 +633,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return isrcMatches[0], nil } - if len(result.Tracks.Items) == 0 { + if len(candidates) == 0 { return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) } @@ -725,6 +690,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam var allTracks []QobuzTrack searchedQueries := make(map[string]bool) + seenTrackIDs := make(map[int64]struct{}) for _, query := range queries { cleanQuery := strings.TrimSpace(query) @@ -735,38 +701,26 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam GoLog("[Qobuz] Searching for: %s\n", cleanQuery) - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - continue - } - - resp, err := DoRequestWithUserAgent(q.client, req) + result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50) if err != nil { GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) continue } - if resp.StatusCode != 200 { - resp.Body.Close() - continue - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - resp.Body.Close() - continue - } - resp.Body.Close() - - if len(result.Tracks.Items) > 0 { - GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery) - allTracks = append(allTracks, result.Tracks.Items...) + if len(result) > 0 { + GoLog("[Qobuz] Found %d results for '%s'\n", len(result), cleanQuery) + for i := range result { + trackID := result[i].ID + if trackID <= 0 { + allTracks = append(allTracks, result[i]) + continue + } + if _, ok := seenTrackIDs[trackID]; ok { + continue + } + seenTrackIDs[trackID] = struct{}{} + allTracks = append(allTracks, result[i]) + } } } @@ -837,6 +791,131 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } +func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) { + searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID) + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + var result struct { + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Tracks.Items, nil +} + +func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 { + matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1) + if len(matches) == 0 { + return nil + } + + trackIDs := make([]int64, 0, len(matches)) + seen := make(map[int64]struct{}, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + id, err := strconv.ParseInt(string(match[1]), 10, 64) + if err != nil || id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + trackIDs = append(trackIDs, id) + } + return trackIDs +} + +func (q *QobuzDownloader) searchQobuzTracksViaStore(query string, limit int) ([]QobuzTrack, error) { + searchURL := qobuzStoreSearchBaseURL + url.PathEscape(query) + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("store search failed: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + trackIDs := extractQobuzTrackIDsFromStoreSearchHTML(body) + if len(trackIDs) == 0 { + return nil, fmt.Errorf("store search did not contain track IDs") + } + + if limit > 0 && len(trackIDs) > limit { + trackIDs = trackIDs[:limit] + } + + tracks := make([]QobuzTrack, 0, len(trackIDs)) + for _, id := range trackIDs { + track, trackErr := q.GetTrackByID(id) + if trackErr != nil || track == nil { + continue + } + tracks = append(tracks, *track) + } + + if len(tracks) == 0 { + return nil, fmt.Errorf("store fallback returned IDs but no track metadata could be loaded") + } + return tracks, nil +} + +func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) ([]QobuzTrack, error) { + apiTracks, apiErr := q.searchQobuzTracksViaAPI(query, limit) + if apiErr == nil { + if len(apiTracks) > 0 { + return apiTracks, nil + } + GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query) + } else { + GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr) + } + + storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit) + if storeErr == nil && len(storeTracks) > 0 { + GoLog("[Qobuz] Store fallback returned %d candidate tracks for '%s'\n", len(storeTracks), query) + return storeTracks, nil + } + + if apiErr != nil && storeErr != nil { + return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr) + } + if storeErr != nil { + return nil, storeErr + } + return nil, fmt.Errorf("no tracks found for query: %s", query) +} + type qobuzAPIResult struct { provider qobuzAPIProvider info qobuzDownloadInfo diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 975573df..b5dfd58d 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,6 +1,7 @@ package gobackend import ( + "context" "encoding/json" "fmt" "net/http" @@ -36,6 +37,12 @@ var ( songLinkClientOnce sync.Once songLinkRegion = "US" songLinkRegionMu sync.RWMutex + songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) { + return GetDeezerClient().SearchByISRC(ctx, isrc) + } + songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) { + return s.CheckAvailabilityFromDeezer(deezerTrackID) + } ) func NewSongLinkClient() *SongLinkClient { @@ -109,6 +116,20 @@ func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry stri } func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + isrc = strings.ToUpper(strings.TrimSpace(isrc)) + + switch { + case spotifyTrackID != "": + return s.checkTrackAvailabilityFromSpotify(spotifyTrackID) + case isrc != "": + return s.checkTrackAvailabilityFromISRC(isrc) + default: + return nil, fmt.Errorf("spotify track ID and ISRC are empty") + } +} + +func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) @@ -200,6 +221,47 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return availability, nil } +func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + + track, err := songLinkSearchByISRC(ctx, isrc) + if err != nil { + return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err) + } + + deezerTrackID := songLinkExtractDeezerTrackID(track) + if deezerTrackID == "" { + return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc) + } + + availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID) + if err != nil { + return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err) + } + + return availability, nil +} + +func songLinkExtractDeezerTrackID(track *TrackMetadata) string { + if track == nil { + return "" + } + + if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok { + deezerID = strings.TrimSpace(deezerID) + if deezerID != "" { + return deezerID + } + } + + if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" { + return deezerID + } + + return "" +} + func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { diff --git a/go_backend/youtube.go b/go_backend/youtube.go index fdbadc63..bfbedcbb 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -539,12 +539,65 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { return "", fmt.Errorf("could not extract video ID from URL") } +// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch +// to find a track by artist + title. It filters for tracks only (not videos, +// albums, or playlists) and returns the YouTube Music watch URL for the first +// matching track, or "" if nothing was found. +func searchYouTubeMusicViaExtension(artistName, trackName string) string { + extManager := GetExtensionManager() + searchProviders := extManager.GetSearchProviders() + + // Find the ytmusic-spotiflac extension + var ytProvider *ExtensionProviderWrapper + for _, p := range searchProviders { + if p.extension.ID == "ytmusic-spotiflac" { + ytProvider = p + break + } + } + if ytProvider == nil { + GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n") + return "" + } + + query := strings.TrimSpace(artistName + " " + trackName) + if query == "" { + return "" + } + + GoLog("[YouTube] Searching YT Music extension for: %s\n", query) + results, err := ytProvider.CustomSearch(query, map[string]interface{}{ + "filter": "tracks", + }) + if err != nil { + GoLog("[YouTube] YT Music extension search failed: %v\n", err) + return "" + } + + // Find the first track result (item_type == "track" with a valid video ID) + for _, track := range results { + if track.ItemType != "" && track.ItemType != "track" { + continue + } + videoID := strings.TrimSpace(track.ID) + if videoID == "" { + continue + } + if isYouTubeVideoID(videoID) { + return BuildYouTubeWatchURL(videoID) + } + } + + GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query) + return "" +} + func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { downloader := NewYouTubeDownloader() format, bitrate, quality := parseYouTubeQualityInput(req.Quality) - // URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC + // URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC) var youtubeURL string var lookupErr error @@ -554,7 +607,15 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) } - // Try Spotify ID via SongLink + // Try YT Music extension search first (if installed) - more accurate, tracks only + if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") { + youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName) + if youtubeURL != "" { + GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL) + } + } + + // Fallback: Try Spotify ID via SongLink if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) songlink := NewSongLinkClient() @@ -566,7 +627,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Try Deezer ID via SongLink + // Fallback: Try Deezer ID via SongLink if youtubeURL == "" && req.DeezerID != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) songlink := NewSongLinkClient() @@ -578,7 +639,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Try ISRC via SongLink + // Fallback: Try ISRC via SongLink if youtubeURL == "" && req.ISRC != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) songlink := NewSongLinkClient() diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index e786ec11..48a1c1e2 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.7.0'; - static const String buildNumber = '103'; + static const String version = '3.7.1'; + static const String buildNumber = '104'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index bc9335c4..3a2fb50f 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -74,13 +74,6 @@ class UpdateChecker { return null; } - // Ignore releases from a different major version (e.g. v4.x when we - // rolled back to v3.x). Only offer updates within the same major line. - if (_majorVersion(latestVersion) != _majorVersion(AppInfo.version)) { - _log.i('Skipping update from different major version (current: ${AppInfo.version}, latest: $latestVersion)'); - return null; - } - final body = releaseData['body'] as String? ?? 'No changelog available'; final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); @@ -125,14 +118,6 @@ class UpdateChecker { } } - static int _majorVersion(String version) { - try { - return int.parse(version.split('-').first.split('.').first); - } catch (_) { - return -1; - } - } - static bool _isNewerVersion(String latest, String current) { try { final latestBase = latest.split('-').first; diff --git a/pubspec.yaml b/pubspec.yaml index e109209f..ff4f6473 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.7.0+103 +version: 3.7.1+104 environment: sdk: ^3.10.0 From 36a646e5c001c5f225f610f331a79df61fbe666b Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 6 Mar 2026 21:18:50 +0700 Subject: [PATCH 36/38] feat: add Deezer download service, Qobuz squid.wtf fallback, update changelog --- CHANGELOG.md | 2 + go_backend/deezer_download.go | 217 +++++++++++++++++- lib/providers/extension_provider.dart | 4 +- .../settings/download_settings_page.dart | 4 +- lib/widgets/download_service_picker.dart | 11 + pubspec.lock | 32 ++- 6 files changed, 250 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf8a8cc..25d41bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality). - **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases. - **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in. +- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider. - **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track. - **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary. diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index fa613394..0c64b658 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -15,6 +15,7 @@ import ( ) const deezerYoinkifyURL = "https://yoinkify.lol/api/download" +const deezerMusicDLURL = "https://www.musicdl.me/api/download" type YoinkifyRequest struct { URL string `json:"url"` @@ -194,6 +195,195 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu return nil } +func resolveDeezerTrackURL(req DownloadRequest) (string, error) { + deezerID := strings.TrimSpace(req.DeezerID) + if deezerID == "" { + if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found { + deezerID = strings.TrimSpace(prefixed) + } + } + if deezerID != "" { + return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + } + + // Try resolving Deezer ID from Spotify ID via SongLink + spotifyID := strings.TrimSpace(req.SpotifyID) + if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { + songlink := NewSongLinkClient() + availability, err := songlink.CheckTrackAvailability(spotifyID, "") + if err == nil && availability.Deezer && availability.DeezerURL != "" { + return availability.DeezerURL, nil + } + } + + // Try resolving from ISRC + isrc := strings.TrimSpace(req.ISRC) + if isrc != "" { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + track, err := GetDeezerClient().SearchByISRC(ctx, isrc) + if err == nil && track != nil { + deezerID = songLinkExtractDeezerTrackID(track) + if deezerID != "" { + return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + } + } + } + + return "", fmt.Errorf("could not resolve Deezer track URL") +} + +type deezerMusicDLRequest struct { + Platform string `json:"platform"` + URL string `json:"url"` +} + +func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) { + payload := deezerMusicDLRequest{ + Platform: "deezer", + URL: deezerTrackURL, + } + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to encode MusicDL request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create MusicDL request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Debug-Key", getQobuzDebugKey()) + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("MusicDL request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return "", fmt.Errorf("failed to read MusicDL response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return "", fmt.Errorf("invalid MusicDL JSON: %w", err) + } + + if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { + return "", fmt.Errorf("MusicDL error: %s", errMsg) + } + + // Try various response fields for download URL + for _, key := range []string{"download_url", "url", "link"} { + if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + } + if data, ok := raw["data"].(map[string]any); ok { + for _, key := range []string{"download_url", "url", "link"} { + if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + } + } + + return "", fmt.Errorf("no download URL found in MusicDL response") +} + +func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error { + GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL) + + downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL) + if err != nil { + return err + } + GoLog("[Deezer] MusicDL returned download URL, starting download...\n") + + ctx := context.Background() + if itemID != "" { + StartItemProgress(itemID) + defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + } + + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned HTTP %d", resp.StatusCode) + } + + expectedSize := resp.ContentLength + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) + } + + out, err := openOutputForWrite(outputPath, outputFD) + if err != nil { + return err + } + + bufWriter := bufio.NewWriterSize(out, 256*1024) + var written int64 + if itemID != "" { + pw := NewItemProgressWriter(bufWriter, itemID) + written, err = io.Copy(pw, resp.Body) + } else { + written, err = io.Copy(bufWriter, resp.Body) + } + + flushErr := bufWriter.Flush() + closeErr := out.Close() + + if err != nil { + cleanupOutputOnError(outputPath, outputFD) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to flush output: %w", flushErr) + } + if closeErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to close output: %w", closeErr) + } + + if expectedSize > 0 && written != expectedSize { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024)) + return nil +} + func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { deezerClient := GetDeezerClient() isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" @@ -254,11 +444,30 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { ) }() - if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return DeezerDownloadResult{}, ErrDownloadCancelled + // Try MusicDL first (better quality), fallback to Yoinkify + var downloadErr error + deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req) + if deezerURLErr == nil { + GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) + downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr) + } + } else { + GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr) + } + + if downloadErr != nil || deezerURLErr != nil { + downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr) } - return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err) } <-parallelDone diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 1963e4cc..1b55f744 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -811,7 +811,7 @@ class ExtensionNotifier extends Notifier { } } - for (final provider in const ['tidal', 'qobuz', 'amazon']) { + for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) { if (!result.contains(provider)) { result.add(provider); } @@ -880,7 +880,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz', 'amazon']; + final providers = ['tidal', 'qobuz', 'amazon', 'deezer']; for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 37918235..2e1e8e8c 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'amazon']; + static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer']; static const _songLinkRegions = [ 'AD', 'AE', @@ -2039,7 +2039,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 21b05b5e..881b6101 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -78,6 +78,17 @@ const _builtInServices = [ ), ], ), + BuiltInService( + id: 'deezer', + label: 'Deezer', + qualityOptions: [ + QualityOption( + id: 'FLAC', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz (CD Quality)', + ), + ], + ), BuiltInService( id: 'youtube', label: 'YouTube', diff --git a/pubspec.lock b/pubspec.lock index 0370159a..4fc7941f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -557,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -625,18 +633,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1158,26 +1166,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" timezone: dependency: transitive description: From 91548691adb36302081908752459c2ff6af04eb7 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 6 Mar 2026 21:57:39 +0700 Subject: [PATCH 37/38] feat(site): add Ruubiiiii as Qobuz & Deezer API provider on partners page --- site/partners.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/site/partners.html b/site/partners.html index 58af382e..0674391a 100644 --- a/site/partners.html +++ b/site/partners.html @@ -523,6 +523,23 @@ + + + +
+
+ +
+
+
Ruubiiiii
+
Qobuz and Deezer download API provider. Hosts the MusicDL API that powers both Qobuz lossless (up to 24-bit/192kHz) and Deezer FLAC (CD Quality) downloads in SpotiFLAC.
+ + Ruubiiiii + + +
+
+ From 4a61ffea8d766dd389a9b8a61a3e887bb88c7336 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 6 Mar 2026 22:00:24 +0700 Subject: [PATCH 38/38] chore: update VirusTotal hash --- README.md | 2 +- lib/screens/settings/about_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f3e14cda..83669b33 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) -[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 1765647c..5b5bc299 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -153,7 +153,7 @@ class AboutPage extends StatelessWidget { ), _ContributorItem( name: 'Ruubiiiii', - description: 'Provided Qobuz API for the project', + description: 'Provided Qobuz & Deezer API for the project', githubUsername: 'Ruubiiiii', showDivider: false, ),