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