From 8e6cbcbc2abc9629629581ddff05d955db353796 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 17 Feb 2026 17:08:02 +0700 Subject: [PATCH] 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