Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| 8e6cbcbc2a | |||
| 8c722b0a18 | |||
| 3ece6770e1 |
@@ -22,13 +22,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: site
|
path: site
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# 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
|
## [3.6.8] - 2026-02-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1a1a2e</color>
|
<color name="ic_launcher_background">#000000</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1521,7 +1521,7 @@ func errorResponse(msg string) (string, error) {
|
|||||||
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
|
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
|
||||||
|
|
||||||
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
|
// 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
|
// It does NOT participate in the lossless fallback chain
|
||||||
func DownloadFromYouTube(requestJSON string) (string, error) {
|
func DownloadFromYouTube(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ go 1.25.0
|
|||||||
toolchain go1.26.0
|
toolchain go1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
|
|||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||||
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
@@ -36,6 +38,8 @@ golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBr
|
|||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||||
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||||
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
|||||||
@@ -790,8 +790,18 @@ func simplifyTrackName(name string) string {
|
|||||||
re := regexp.MustCompile("(?i)" + pattern)
|
re := regexp.MustCompile("(?i)" + pattern)
|
||||||
result = re.ReplaceAllString(result, "")
|
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 {
|
func normalizeArtistName(name string) string {
|
||||||
|
|||||||
@@ -174,6 +174,26 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
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)
|
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
|
|||||||
@@ -1289,6 +1289,26 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
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)
|
expectedLatin := isLatinScript(expectedTitle)
|
||||||
foundLatin := isLatinScript(foundTitle)
|
foundLatin := isLatinScript(foundTitle)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,6 +21,8 @@ type YouTubeDownloader struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spotubeBaseURL = "https://spotubedl.com"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalYouTubeDownloader *YouTubeDownloader
|
globalYouTubeDownloader *YouTubeDownloader
|
||||||
youtubeDownloaderOnce sync.Once
|
youtubeDownloaderOnce sync.Once
|
||||||
@@ -29,9 +32,17 @@ type YouTubeQuality string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
|
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||||
|
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||||
|
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||||
|
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||||
|
)
|
||||||
|
|
||||||
type CobaltRequest struct {
|
type CobaltRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||||
@@ -79,6 +90,77 @@ func NewYouTubeDownloader() *YouTubeDownloader {
|
|||||||
return globalYouTubeDownloader
|
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
|
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||||
@@ -95,22 +177,11 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
|||||||
y.mu.Lock()
|
y.mu.Lock()
|
||||||
defer y.mu.Unlock()
|
defer y.mu.Unlock()
|
||||||
|
|
||||||
var audioFormat string
|
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||||
var audioBitrate string
|
audioBitrate := strconv.Itoa(bitrate)
|
||||||
|
|
||||||
switch quality {
|
|
||||||
case YouTubeQualityOpus256:
|
|
||||||
audioFormat = "opus"
|
|
||||||
audioBitrate = "256"
|
|
||||||
case YouTubeQualityMP3320:
|
|
||||||
audioFormat = "mp3"
|
|
||||||
audioBitrate = "320"
|
|
||||||
default:
|
|
||||||
audioFormat = "mp3"
|
|
||||||
audioBitrate = "320"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try SpotubeDL first (primary)
|
// Try SpotubeDL first (primary)
|
||||||
|
var spotubeErr error
|
||||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||||
if extractErr == nil {
|
if extractErr == nil {
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
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 {
|
if err == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
spotubeErr = err
|
||||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
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)
|
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||||
if err != nil {
|
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)
|
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).
|
// 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) {
|
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",
|
engines := []string{"v1"}
|
||||||
videoID, audioFormat, audioBitrate)
|
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)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != 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)
|
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 {
|
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 {
|
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 {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.URL == "" {
|
downloadURL := strings.TrimSpace(result.URL)
|
||||||
return nil, fmt.Errorf("no download URL from spotubedl")
|
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{
|
return &CobaltResponse{
|
||||||
Status: "tunnel",
|
Status: "tunnel",
|
||||||
URL: result.URL,
|
URL: downloadURL,
|
||||||
|
Filename: filename,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,15 +542,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|||||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||||
downloader := NewYouTubeDownloader()
|
downloader := NewYouTubeDownloader()
|
||||||
|
|
||||||
var quality YouTubeQuality
|
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||||
var youtubeURL string
|
var youtubeURL string
|
||||||
@@ -480,18 +603,23 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext string
|
ext := ".mp3"
|
||||||
var format string
|
if format == "opus" {
|
||||||
var bitrate int
|
|
||||||
switch quality {
|
|
||||||
case YouTubeQualityOpus256:
|
|
||||||
ext = ".opus"
|
ext = ".opus"
|
||||||
format = "opus"
|
}
|
||||||
bitrate = 256
|
|
||||||
case YouTubeQualityMP3320:
|
// Some SpotubeDL engines may return a different output container than requested.
|
||||||
ext = ".mp3"
|
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||||
format = "mp3"
|
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||||
bitrate = 320
|
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{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 744 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 419 B After Width: | Height: | Size: 624 B |
|
Before Width: | Height: | Size: 789 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 717 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.6.7';
|
static const String version = '3.6.9';
|
||||||
static const String buildNumber = '81';
|
static const String buildNumber = '82';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3508,6 +3508,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
||||||
String get youtubeQualityNote;
|
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
|
/// Setting - show quality picker
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -1944,6 +1944,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1929,6 +1929,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,30 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1935,6 +1935,30 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
|
|
||||||
|
|||||||
@@ -1911,6 +1911,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||||
|
|
||||||
|
|||||||
@@ -1922,6 +1922,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,30 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,30 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1963,6 +1963,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||||
|
|
||||||
|
|||||||
@@ -1938,6 +1938,30 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1420,6 +1420,37 @@
|
|||||||
"@qualityNote": {"description": "Note about quality availability"},
|
"@qualityNote": {"description": "Note about quality availability"},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||||
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {"description": "Title for YouTube Opus bitrate setting"},
|
||||||
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
||||||
|
|||||||
@@ -2506,6 +2506,57 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"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": "Tanya Sebelum Unduh",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class AppSettings {
|
|||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
final String
|
||||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
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
|
final bool
|
||||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
final bool
|
final bool
|
||||||
@@ -103,6 +107,8 @@ class AppSettings {
|
|||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
this.tidalHighFormat = 'mp3_320',
|
||||||
|
this.youtubeOpusBitrate = 256,
|
||||||
|
this.youtubeMp3Bitrate = 320,
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
this.autoExportFailedDownloads = false,
|
this.autoExportFailedDownloads = false,
|
||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
@@ -113,10 +119,16 @@ class AppSettings {
|
|||||||
// Tutorial default
|
// Tutorial default
|
||||||
this.hasCompletedTutorial = false,
|
this.hasCompletedTutorial = false,
|
||||||
// Lyrics providers default order
|
// 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.lyricsIncludeTranslationNetease = false,
|
||||||
this.lyricsIncludeRomanizationNetease = false,
|
this.lyricsIncludeRomanizationNetease = false,
|
||||||
this.lyricsMultiPersonWordByWord = true,
|
this.lyricsMultiPersonWordByWord = false,
|
||||||
this.musixmatchLanguage = '',
|
this.musixmatchLanguage = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,6 +168,8 @@ class AppSettings {
|
|||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
String? tidalHighFormat,
|
||||||
|
int? youtubeOpusBitrate,
|
||||||
|
int? youtubeMp3Bitrate,
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
bool? autoExportFailedDownloads,
|
bool? autoExportFailedDownloads,
|
||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
@@ -215,6 +229,8 @@ class AppSettings {
|
|||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
|
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||||
|
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
@@ -229,9 +245,11 @@ class AppSettings {
|
|||||||
// Lyrics providers
|
// Lyrics providers
|
||||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||||
lyricsIncludeTranslationNetease:
|
lyricsIncludeTranslationNetease:
|
||||||
lyricsIncludeTranslationNetease ?? this.lyricsIncludeTranslationNetease,
|
lyricsIncludeTranslationNetease ??
|
||||||
|
this.lyricsIncludeTranslationNetease,
|
||||||
lyricsIncludeRomanizationNetease:
|
lyricsIncludeRomanizationNetease:
|
||||||
lyricsIncludeRomanizationNetease ?? this.lyricsIncludeRomanizationNetease,
|
lyricsIncludeRomanizationNetease ??
|
||||||
|
this.lyricsIncludeRomanizationNetease,
|
||||||
lyricsMultiPersonWordByWord:
|
lyricsMultiPersonWordByWord:
|
||||||
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||||
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
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,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||||
@@ -63,7 +65,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
lyricsIncludeRomanizationNetease:
|
lyricsIncludeRomanizationNetease:
|
||||||
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
|
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
|
||||||
lyricsMultiPersonWordByWord:
|
lyricsMultiPersonWordByWord:
|
||||||
json['lyricsMultiPersonWordByWord'] as bool? ?? true,
|
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
|
||||||
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,6 +107,8 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
|
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||||
|
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
|
|||||||
@@ -2771,7 +2771,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
settings,
|
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 isSafMode = _isSafMode(settings);
|
||||||
final relativeOutputDir = isSafMode
|
final relativeOutputDir = isSafMode
|
||||||
? await _buildRelativeOutputDir(
|
? await _buildRelativeOutputDir(
|
||||||
@@ -3032,8 +3054,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
// Keep prior behavior: non-YouTube paths were implicitly true.
|
embedLyrics: settings.embedLyrics,
|
||||||
embedLyrics: isYouTube ? settings.embedLyrics : true,
|
|
||||||
embedMaxQualityCover: settings.maxQualityCover,
|
embedMaxQualityCover: settings.maxQualityCover,
|
||||||
trackNumber: normalizedTrackNumber,
|
trackNumber: normalizedTrackNumber,
|
||||||
discNumber: normalizedDiscNumber,
|
discNumber: normalizedDiscNumber,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const _spotifyClientSecretKey = 'spotify_client_secret';
|
|||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
|
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||||
|
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
bool _isSavingSettings = false;
|
bool _isSavingSettings = false;
|
||||||
@@ -32,6 +35,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
|
await _normalizeYouTubeBitratesIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _loadSpotifyClientSecret(prefs);
|
await _loadSpotifyClientSecret(prefs);
|
||||||
@@ -107,6 +111,49 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _nearestSupportedBitrate(int value, List<int> 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<void> _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<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||||
final storedSecret = await _secureStorage.read(
|
final storedSecret = await _secureStorage.read(
|
||||||
key: _spotifyClientSecretKey,
|
key: _spotifyClientSecretKey,
|
||||||
@@ -230,7 +277,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setMusixmatchLanguage(String languageCode) {
|
void setMusixmatchLanguage(String languageCode) {
|
||||||
state = state.copyWith(musixmatchLanguage: languageCode.trim().toLowerCase());
|
state = state.copyWith(
|
||||||
|
musixmatchLanguage: languageCode.trim().toLowerCase(),
|
||||||
|
);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
_syncLyricsSettingsToBackend();
|
_syncLyricsSettingsToBackend();
|
||||||
}
|
}
|
||||||
@@ -390,6 +439,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_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) {
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
state = state.copyWith(useAllFilesAccess: enabled);
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -165,6 +165,16 @@ class _RecentDonorsCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
const donorNames = [
|
||||||
|
'J',
|
||||||
|
'Julian',
|
||||||
|
'matt_3050',
|
||||||
|
'Daniel',
|
||||||
|
'283Fabio',
|
||||||
|
'laflame',
|
||||||
|
'Elias el Autentico',
|
||||||
|
'Faylyne',
|
||||||
|
];
|
||||||
|
|
||||||
// Match SettingsGroup color logic
|
// Match SettingsGroup color logic
|
||||||
final cardColor = isDark
|
final cardColor = isDark
|
||||||
@@ -207,16 +217,15 @@ class _RecentDonorsCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
Wrap(
|
||||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
spacing: 8,
|
||||||
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
runSpacing: 8,
|
||||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
children: donorNames
|
||||||
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
.map(
|
||||||
_DonorTile(name: 'laflame', colorScheme: colorScheme),
|
(name) =>
|
||||||
_DonorTile(
|
_SupporterChip(name: name, colorScheme: colorScheme),
|
||||||
name: 'Elias el Autentico',
|
)
|
||||||
colorScheme: colorScheme,
|
.toList(),
|
||||||
showDivider: false,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -348,55 +357,45 @@ class _DonateCardItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DonorTile extends StatelessWidget {
|
class _SupporterChip extends StatelessWidget {
|
||||||
final String name;
|
final String name;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
final bool showDivider;
|
|
||||||
|
|
||||||
const _DonorTile({
|
const _SupporterChip({required this.name, required this.colorScheme});
|
||||||
required this.name,
|
|
||||||
required this.colorScheme,
|
|
||||||
this.showDivider = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Material(
|
||||||
mainAxisSize: MainAxisSize.min,
|
color: colorScheme.secondaryContainer,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(20),
|
||||||
Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
CircleAvatar(
|
children: [
|
||||||
radius: 18,
|
CircleAvatar(
|
||||||
backgroundColor: colorScheme.primaryContainer,
|
radius: 10,
|
||||||
child: Text(
|
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
child: Text(
|
||||||
style: TextStyle(
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
fontSize: 14,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 10,
|
||||||
color: colorScheme.onPrimaryContainer,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Text(
|
const SizedBox(width: 8),
|
||||||
name,
|
Text(
|
||||||
style: Theme.of(
|
name,
|
||||||
context,
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
if (showDivider)
|
),
|
||||||
Divider(
|
|
||||||
height: 1,
|
|
||||||
thickness: 1,
|
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,33 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
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,73 +298,88 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.lyrics_outlined,
|
icon: Icons.subtitles_outlined,
|
||||||
title: context.l10n.lyricsMode,
|
title: context.l10n.optionsEmbedLyrics,
|
||||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||||
onTap: () => _showLyricsModePicker(
|
value: settings.embedLyrics,
|
||||||
context,
|
onChanged: (value) => ref
|
||||||
ref,
|
.read(settingsProvider.notifier)
|
||||||
settings.lyricsMode,
|
.setEmbedLyrics(value),
|
||||||
),
|
showDivider: settings.embedLyrics,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
if (settings.embedLyrics) ...[
|
||||||
icon: Icons.source_outlined,
|
SettingsItem(
|
||||||
title: 'Lyrics Providers',
|
icon: Icons.lyrics_outlined,
|
||||||
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
title: context.l10n.lyricsMode,
|
||||||
onTap: () => Navigator.push(
|
subtitle:
|
||||||
context,
|
_getLyricsModeLabel(context, settings.lyricsMode),
|
||||||
MaterialPageRoute(
|
onTap: () => _showLyricsModePicker(
|
||||||
builder: (_) => const LyricsProviderPriorityPage(),
|
context,
|
||||||
|
ref,
|
||||||
|
settings.lyricsMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SettingsItem(
|
||||||
SettingsSwitchItem(
|
icon: Icons.source_outlined,
|
||||||
icon: Icons.translate_outlined,
|
title: 'Lyrics Providers',
|
||||||
title: 'Netease: Include Translation',
|
subtitle: _getLyricsProvidersSubtitle(
|
||||||
subtitle: settings.lyricsIncludeTranslationNetease
|
settings.lyricsProviders,
|
||||||
? 'Append translated lyrics when available'
|
),
|
||||||
: 'Use original lyrics only',
|
onTap: () => Navigator.push(
|
||||||
value: settings.lyricsIncludeTranslationNetease,
|
context,
|
||||||
onChanged: (value) => ref
|
MaterialPageRoute(
|
||||||
.read(settingsProvider.notifier)
|
builder: (_) => const LyricsProviderPriorityPage(),
|
||||||
.setLyricsIncludeTranslationNetease(value),
|
),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
|
||||||
icon: Icons.text_fields_outlined,
|
|
||||||
title: 'Netease: Include Romanization',
|
|
||||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
|
||||||
? 'Append romanized lyrics when available'
|
|
||||||
: 'Disabled',
|
|
||||||
value: settings.lyricsIncludeRomanizationNetease,
|
|
||||||
onChanged: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setLyricsIncludeRomanizationNetease(value),
|
|
||||||
),
|
|
||||||
SettingsSwitchItem(
|
|
||||||
icon: Icons.record_voice_over_outlined,
|
|
||||||
title: 'Apple/QQ Multi-Person Word-by-Word',
|
|
||||||
subtitle: settings.lyricsMultiPersonWordByWord
|
|
||||||
? 'Enable v1/v2 speaker and [bg:] tags'
|
|
||||||
: 'Simplified word-by-word formatting',
|
|
||||||
value: settings.lyricsMultiPersonWordByWord,
|
|
||||||
onChanged: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setLyricsMultiPersonWordByWord(value),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.language_outlined,
|
|
||||||
title: 'Musixmatch Language',
|
|
||||||
subtitle: settings.musixmatchLanguage.isEmpty
|
|
||||||
? 'Auto (original)'
|
|
||||||
: settings.musixmatchLanguage.toUpperCase(),
|
|
||||||
onTap: () => _showMusixmatchLanguagePicker(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
settings.musixmatchLanguage,
|
|
||||||
),
|
),
|
||||||
showDivider: false,
|
SettingsSwitchItem(
|
||||||
),
|
icon: Icons.translate_outlined,
|
||||||
|
title: 'Netease: Include Translation',
|
||||||
|
subtitle: settings.lyricsIncludeTranslationNetease
|
||||||
|
? 'Append translated lyrics when available'
|
||||||
|
: 'Use original lyrics only',
|
||||||
|
value: settings.lyricsIncludeTranslationNetease,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsIncludeTranslationNetease(value),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.text_fields_outlined,
|
||||||
|
title: 'Netease: Include Romanization',
|
||||||
|
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||||
|
? 'Append romanized lyrics when available'
|
||||||
|
: 'Disabled',
|
||||||
|
value: settings.lyricsIncludeRomanizationNetease,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsIncludeRomanizationNetease(value),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.record_voice_over_outlined,
|
||||||
|
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||||
|
subtitle: settings.lyricsMultiPersonWordByWord
|
||||||
|
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||||
|
: 'Simplified word-by-word formatting',
|
||||||
|
value: settings.lyricsMultiPersonWordByWord,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsMultiPersonWordByWord(value),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.language_outlined,
|
||||||
|
title: 'Musixmatch Language',
|
||||||
|
subtitle: settings.musixmatchLanguage.isEmpty
|
||||||
|
? 'Auto (original)'
|
||||||
|
: settings.musixmatchLanguage.toUpperCase(),
|
||||||
|
onTap: () => _showMusixmatchLanguagePicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.musixmatchLanguage,
|
||||||
|
),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1250,9 +1292,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
|
|
||||||
String _getLyricsProvidersSubtitle(List<String> providers) {
|
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||||
if (providers.isEmpty) return 'None enabled';
|
if (providers.isEmpty) return 'None enabled';
|
||||||
return providers
|
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
|
||||||
.map((p) => _providerDisplayNames[p] ?? p)
|
|
||||||
.join(' > ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _normalizeMusixmatchLanguage(String value) {
|
String _normalizeMusixmatchLanguage(String value) {
|
||||||
@@ -1260,6 +1300,67 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showYoutubeBitratePicker({
|
||||||
|
required BuildContext context,
|
||||||
|
required String title,
|
||||||
|
required int currentValue,
|
||||||
|
required List<int> 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(
|
void _showMusixmatchLanguagePicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -1288,9 +1389,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Musixmatch Language',
|
'Musixmatch Language',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -1319,7 +1420,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(settingsProvider.notifier).setMusixmatchLanguage('');
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setMusixmatchLanguage('');
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Auto'),
|
child: const Text('Auto'),
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
|
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
|
||||||
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
|
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<String> _months = [
|
static const List<String> _months = [
|
||||||
'Jan',
|
'Jan',
|
||||||
'Feb',
|
'Feb',
|
||||||
@@ -1722,9 +1725,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
String _buildSaveBaseName() {
|
||||||
final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
final artist = _sanitizeFileNameSegment(artistName);
|
||||||
final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
final track = _sanitizeFileNameSegment(trackName);
|
||||||
return '$artist - $track';
|
return '$artist - $track';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@@ -41,17 +42,7 @@ class CoverCacheManager {
|
|||||||
|
|
||||||
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
||||||
|
|
||||||
_instance = CacheManager(
|
_instance = _createManager(_cachePath!);
|
||||||
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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
debugPrint('CoverCacheManager: Initialized successfully');
|
debugPrint('CoverCacheManager: Initialized successfully');
|
||||||
@@ -62,12 +53,47 @@ class CoverCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> clearCache() async {
|
static Future<void> clearCache() async {
|
||||||
if (!_initialized || _instance == null) return;
|
if (!_initialized || _instance == null || _cachePath == null) {
|
||||||
await _instance!.emptyCache();
|
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<CacheStats> getStats() async {
|
static Future<CacheStats> 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);
|
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +119,45 @@ class CoverCacheManager {
|
|||||||
|
|
||||||
return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
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<void> _wipeDirectory(String path) async {
|
||||||
|
final directory = Directory(path);
|
||||||
|
if (!await directory.exists()) {
|
||||||
|
await directory.create(recursive: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final entities = <FileSystemEntity>[];
|
||||||
|
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 {
|
class CacheStats {
|
||||||
|
|||||||
@@ -29,18 +29,42 @@ const _builtInServices = [
|
|||||||
id: 'tidal',
|
id: 'tidal',
|
||||||
label: 'Tidal',
|
label: 'Tidal',
|
||||||
qualityOptions: [
|
qualityOptions: [
|
||||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
QualityOption(
|
||||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
id: 'LOSSLESS',
|
||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
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(
|
BuiltInService(
|
||||||
id: 'qobuz',
|
id: 'qobuz',
|
||||||
label: 'Qobuz',
|
label: 'Qobuz',
|
||||||
qualityOptions: [
|
qualityOptions: [
|
||||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
QualityOption(
|
||||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
id: 'LOSSLESS',
|
||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
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(
|
BuiltInService(
|
||||||
@@ -58,8 +82,16 @@ const _builtInServices = [
|
|||||||
id: 'youtube',
|
id: 'youtube',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
qualityOptions: [
|
qualityOptions: [
|
||||||
QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'),
|
QualityOption(
|
||||||
QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'),
|
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,
|
isDisabled: false,
|
||||||
disabledReason: null,
|
disabledReason: null,
|
||||||
@@ -82,7 +114,8 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<DownloadServicePicker> createState() => _DownloadServicePickerState();
|
ConsumerState<DownloadServicePicker> createState() =>
|
||||||
|
_DownloadServicePickerState();
|
||||||
|
|
||||||
/// Show the download service picker as a modal bottom sheet
|
/// Show the download service picker as a modal bottom sheet
|
||||||
static void show(
|
static void show(
|
||||||
@@ -112,6 +145,9 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||||
|
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||||
|
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||||
|
|
||||||
late String _selectedService;
|
late String _selectedService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -122,23 +158,71 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
|
|
||||||
/// Get quality options for the selected service
|
/// Get quality options for the selected service
|
||||||
List<QualityOption> _getQualityOptions() {
|
List<QualityOption> _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) {
|
if (builtIn != null) {
|
||||||
return builtIn.qualityOptions;
|
return builtIn.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
final extensionState = ref.read(extensionProvider);
|
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) {
|
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||||
return ext.qualityOptions;
|
return ext.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback options
|
// Default fallback options
|
||||||
return [
|
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<int> 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -162,7 +246,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
artistName: widget.artistName,
|
artistName: widget.artistName,
|
||||||
coverUrl: widget.coverUrl,
|
coverUrl: widget.coverUrl,
|
||||||
),
|
),
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Center(
|
Center(
|
||||||
@@ -181,11 +268,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.downloadFrom,
|
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),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -217,11 +306,15 @@ Padding(
|
|||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.downloadSelectQuality,
|
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(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -264,27 +357,27 @@ Padding(
|
|||||||
}
|
}
|
||||||
|
|
||||||
IconData _getQualityIcon(String qualityId) {
|
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':
|
case 'HI_RES_LOSSLESS':
|
||||||
return Icons.four_k;
|
return Icons.four_k;
|
||||||
case 'HI_RES':
|
case 'HI_RES':
|
||||||
return Icons.high_quality;
|
return Icons.high_quality;
|
||||||
case 'LOSSLESS':
|
case 'LOSSLESS':
|
||||||
return Icons.music_note;
|
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:
|
default:
|
||||||
return Icons.music_note;
|
return Icons.music_note;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _QualityOption extends StatelessWidget {
|
class _QualityOption extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
@@ -313,7 +406,10 @@ class _QualityOption extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: subtitle.isNotEmpty
|
subtitle: subtitle.isNotEmpty
|
||||||
? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant))
|
? Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
@@ -347,10 +443,14 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
color: isDisabled
|
color: isDisabled
|
||||||
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||||
: isSelected
|
: isSelected
|
||||||
? colorScheme.primaryContainer
|
? colorScheme.primaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -369,8 +469,8 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
color: isDisabled
|
color: isDisabled
|
||||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||||
: isSelected
|
: isSelected
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -383,8 +483,8 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
color: isDisabled
|
color: isDisabled
|
||||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||||
: isSelected
|
: isSelected
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -419,7 +519,9 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
|||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
onTap: _isOverflowing
|
||||||
|
? () => setState(() => _expanded = !_expanded)
|
||||||
|
: null,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(28),
|
topLeft: Radius.circular(28),
|
||||||
topRight: Radius.circular(28),
|
topRight: Radius.circular(28),
|
||||||
@@ -447,26 +549,39 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) => Container(
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
width: 56,
|
Container(
|
||||||
height: 56,
|
width: 56,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
height: 56,
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
final titleStyle = Theme.of(context)
|
||||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(
|
||||||
|
text: widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
);
|
||||||
final titlePainter = TextPainter(
|
final titlePainter = TextPainter(
|
||||||
text: titleSpan,
|
text: titleSpan,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -487,17 +602,22 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
|||||||
widget.trackName,
|
widget.trackName,
|
||||||
style: titleStyle,
|
style: titleStyle,
|
||||||
maxLines: _expanded ? 10 : 1,
|
maxLines: _expanded ? 10 : 1,
|
||||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
overflow: _expanded
|
||||||
|
? TextOverflow.visible
|
||||||
|
: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (widget.artistName != null) ...[
|
if (widget.artistName != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
widget.artistName!,
|
widget.artistName!,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
color: colorScheme.onSurfaceVariant,
|
?.copyWith(
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
maxLines: _expanded ? 3 : 1,
|
maxLines: _expanded ? 3 : 1,
|
||||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
overflow: _expanded
|
||||||
|
? TextOverflow.visible
|
||||||
|
: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.6.7+81
|
version: 3.6.9+82
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -80,10 +80,13 @@ flutter_launcher_icons:
|
|||||||
android: true
|
android: true
|
||||||
ios: true
|
ios: true
|
||||||
image_path: "icon.png"
|
image_path: "icon.png"
|
||||||
adaptive_icon_background: "#1a1a2e"
|
image_path_android: "icon_android.png"
|
||||||
adaptive_icon_foreground: "icon.png"
|
adaptive_icon_background: "#000000"
|
||||||
|
adaptive_icon_foreground: "icon_foreground_android.png"
|
||||||
|
adaptive_icon_foreground_inset: 16
|
||||||
ios_content_mode: scaleAspectFill
|
ios_content_mode: scaleAspectFill
|
||||||
remove_alpha_ios: true
|
remove_alpha_ios: true
|
||||||
|
background_color_ios: "#000000"
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||