diff --git a/go_backend/tidal.go b/go_backend/tidal.go index ea286f22..de8b1039 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -2109,7 +2109,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { outputExt := strings.TrimSpace(req.OutputExt) if outputExt == "" { - outputExt = ".flac" + if quality == "HIGH" { + outputExt = ".m4a" + } else { + outputExt = ".flac" + } } else if !strings.HasPrefix(outputExt, ".") { outputExt = "." + outputExt } @@ -2123,7 +2127,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } m4aPath = outputPath } else { - if outputExt == ".m4a" { + if outputExt == ".m4a" || quality == "HIGH" { filename = sanitizeFilename(filename) + ".m4a" outputPath = filepath.Join(req.OutputDir, filename) m4aPath = outputPath @@ -2136,8 +2140,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } - if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { - return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil + if quality != "HIGH" { + if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { + return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil + } } } @@ -2293,7 +2299,27 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { - fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") + if quality == "HIGH" { + GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") + + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" + } + + if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { + GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode) + if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Tidal] LRC file saved: %s\n", lrcPath) + } + } + } + } else { + fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") + } } if !isSafOutput { @@ -2302,6 +2328,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { bitDepth := downloadInfo.BitDepth sampleRate := downloadInfo.SampleRate + if quality == "HIGH" { + bitDepth = 0 + sampleRate = 44100 + } lyricsLRC := "" if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d8e291ca..d38202cc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2596,6 +2596,66 @@ abstract class AppLocalizations { /// **'24-bit / up to 192kHz'** String get qualityHiResFlacMaxSubtitle; + /// Quality option label for Tidal lossy 320kbps + /// + /// In en, this message translates to: + /// **'Lossy 320kbps'** + String get downloadLossy320; + + /// Setting title to pick output format for Tidal lossy downloads + /// + /// In en, this message translates to: + /// **'Lossy Format'** + String get downloadLossyFormat; + + /// Title of the Tidal lossy format picker bottom sheet + /// + /// In en, this message translates to: + /// **'Lossy 320kbps Format'** + String get downloadLossy320Format; + + /// Description in the Tidal lossy format picker + /// + /// In en, this message translates to: + /// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'** + String get downloadLossy320FormatDesc; + + /// Tidal lossy format option - MP3 320kbps + /// + /// In en, this message translates to: + /// **'MP3 320kbps'** + String get downloadLossyMp3; + + /// Subtitle for MP3 320kbps Tidal lossy option + /// + /// In en, this message translates to: + /// **'Best compatibility, ~10MB per track'** + String get downloadLossyMp3Subtitle; + + /// Tidal lossy format option - Opus 256kbps + /// + /// In en, this message translates to: + /// **'Opus 256kbps'** + String get downloadLossyOpus256; + + /// Subtitle for Opus 256kbps Tidal lossy option + /// + /// In en, this message translates to: + /// **'Best quality Opus, ~8MB per track'** + String get downloadLossyOpus256Subtitle; + + /// Tidal lossy format option - Opus 128kbps + /// + /// In en, this message translates to: + /// **'Opus 128kbps'** + String get downloadLossyOpus128; + + /// Subtitle for Opus 128kbps Tidal lossy option + /// + /// In en, this message translates to: + /// **'Smallest size, ~4MB per track'** + String get downloadLossyOpus128Subtitle; + /// Note about quality availability /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b58fd78c..7d2e6008 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1413,6 +1413,38 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 328a4a9d..cc37fb89 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1389,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6308f8f4..d99eec4d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1389,6 +1389,38 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f16c40f8..5b1c8d81 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1391,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 2022128f..7c92f95c 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1389,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index dce7f058..8b088f35 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1396,6 +1396,38 @@ class AppLocalizationsId extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 5f1fc709..5c454b45 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1379,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 5d4f256f..3dc6f4f9 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1369,6 +1369,38 @@ class AppLocalizationsKo extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ef14b913..74e93a95 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1389,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index d1fb718a..b53d2e07 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1389,6 +1389,38 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 47398466..810b35a9 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1414,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 7c77c93e..63a54638 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1401,6 +1401,38 @@ class AppLocalizationsTr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5ce4df91..31adb1ff 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1389,6 +1389,38 @@ class AppLocalizationsZh extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 69aa2068..7797e048 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1825,6 +1825,46 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, + "downloadLossy320": "Lossy 320kbps", + "@downloadLossy320": { + "description": "Quality option label for Tidal lossy 320kbps" + }, + "downloadLossyFormat": "Lossy Format", + "@downloadLossyFormat": { + "description": "Setting title to pick output format for Tidal lossy downloads" + }, + "downloadLossy320Format": "Lossy 320kbps Format", + "@downloadLossy320Format": { + "description": "Title of the Tidal lossy format picker bottom sheet" + }, + "downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.", + "@downloadLossy320FormatDesc": { + "description": "Description in the Tidal lossy format picker" + }, + "downloadLossyMp3": "MP3 320kbps", + "@downloadLossyMp3": { + "description": "Tidal lossy format option - MP3 320kbps" + }, + "downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track", + "@downloadLossyMp3Subtitle": { + "description": "Subtitle for MP3 320kbps Tidal lossy option" + }, + "downloadLossyOpus256": "Opus 256kbps", + "@downloadLossyOpus256": { + "description": "Tidal lossy format option - Opus 256kbps" + }, + "downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track", + "@downloadLossyOpus256Subtitle": { + "description": "Subtitle for Opus 256kbps Tidal lossy option" + }, + "downloadLossyOpus128": "Opus 128kbps", + "@downloadLossyOpus128": { + "description": "Tidal lossy format option - Opus 128kbps" + }, + "downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track", + "@downloadLossyOpus128Subtitle": { + "description": "Subtitle for Opus 128kbps Tidal lossy option" + }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c8848ab7..32b3b76c 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -39,6 +39,8 @@ class AppSettings { final bool showExtensionStore; final String locale; final String lyricsMode; + final String + tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' final int youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) final int @@ -116,6 +118,7 @@ class AppSettings { this.showExtensionStore = true, this.locale = 'system', this.lyricsMode = 'embed', + this.tidalHighFormat = 'mp3_320', this.youtubeOpusBitrate = 256, this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, @@ -181,6 +184,7 @@ class AppSettings { bool? showExtensionStore, String? locale, String? lyricsMode, + String? tidalHighFormat, int? youtubeOpusBitrate, int? youtubeMp3Bitrate, bool? useAllFilesAccess, @@ -245,6 +249,7 @@ class AppSettings { showExtensionStore: showExtensionStore ?? this.showExtensionStore, locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, + tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 026569dd..85244329 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -45,6 +45,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( showExtensionStore: json['showExtensionStore'] as bool? ?? true, locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', + tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, @@ -121,6 +122,7 @@ Map _$AppSettingsToJson( 'showExtensionStore': instance.showExtensionStore, 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, + 'tidalHighFormat': instance.tidalHighFormat, 'youtubeOpusBitrate': instance.youtubeOpusBitrate, 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4a308850..c07f50a6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1927,7 +1927,7 @@ class DownloadQueueNotifier extends Notifier { return '.opus'; } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { - return '.flac'; // HIGH quality no longer available; fallback to FLAC + return '.m4a'; } return '.flac'; } @@ -4163,50 +4163,72 @@ class DownloadQueueNotifier extends Notifier { final currentFilePath = filePath; if (isContentUriPath && effectiveSafMode) { - _log.d('M4A file detected (SAF), converting to FLAC...'); - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - String? flacPath; - try { - final length = await File(tempPath).length(); - if (length < 1024) { - _log.w('Temp M4A is too small (<1KB), skipping conversion'); - } else { + if (quality == 'HIGH') { + final tidalHighFormat = settings.tidalHighFormat; + _log.i( + 'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...', + ); + + final tempPath = await _copySafToTemp(currentFilePath); + if (tempPath != null) { + String? convertedPath; + try { updateItemStatus( item.id, DownloadStatus.downloading, progress: 0.95, ); - flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath != null) { - _log.d('Converted to FLAC (temp): $flacPath'); - _log.d('Embedding metadata and cover to converted FLAC...'); - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, + + final format = + tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + convertedPath = await FFmpegService.convertM4aToLossy( + tempPath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: false, + ); + + if (convertedPath != null) { + _log.i( + 'Successfully converted M4A to $format (temp): $convertedPath', + ); + _log.i('Embedding metadata to $format...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - await _embedMetadataAndCover( - flacPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - writeExternalLrc: false, - ); + if (format == 'mp3') { + await _embedMetadataToMp3( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else { + await _embedMetadataToOpus( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } - final newFileName = '${safBaseName ?? 'track'}.flac'; + final newExt = format == 'opus' ? '.opus' : '.mp3'; + final newFileName = '${safBaseName ?? 'track'}$newExt'; final newUri = await _writeTempToSaf( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: flacPath, + mimeType: _mimeTypeForExt(newExt), + srcPath: convertedPath, ); if (newUri != null) { @@ -4215,60 +4237,57 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + actualQuality = '${format.toUpperCase()} $bitrateDisplay'; } else { - _log.w('Failed to write FLAC to SAF, keeping M4A'); + _log.w( + 'Failed to write converted $format to SAF, keeping M4A', + ); + actualQuality = 'AAC 320kbps'; } } else { - _log.w('FFmpeg conversion returned null, keeping M4A file'); + _log.w( + 'M4A to $format conversion failed, keeping M4A file', + ); + actualQuality = 'AAC 320kbps'; + } + } catch (e) { + _log.w('SAF M4A conversion failed: $e'); + actualQuality = 'AAC 320kbps'; + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (convertedPath != null) { + try { + await File(convertedPath).delete(); + } catch (_) {} } } - } catch (e) { - _log.w('SAF M4A->FLAC conversion failed: $e'); - } finally { - // Clean up temp files - try { - await File(tempPath).delete(); - } catch (_) {} - if (flacPath != null) { - try { - await File(flacPath).delete(); - } catch (_) {} - } } - } - } else { - _log.d( - 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', - ); - - try { - final file = File(currentFilePath); - if (!await file.exists()) { - _log.e('File does not exist at path: $filePath'); - } else { - final length = await file.length(); - _log.i('File size before conversion: ${length / 1024} KB'); - - if (length < 1024) { - _log.w( - 'File is too small (<1KB), skipping conversion. Download might be corrupt.', - ); - } else { - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.95, - ); - final flacPath = await FFmpegService.convertM4aToFlac( - currentFilePath, - ); - - if (flacPath != null) { - filePath = flacPath; - _log.d('Converted to FLAC: $flacPath'); - - _log.d('Embedding metadata and cover to converted FLAC...'); - try { + } else { + _log.d('M4A file detected (SAF), converting to FLAC...'); + final tempPath = await _copySafToTemp(currentFilePath); + if (tempPath != null) { + String? flacPath; + try { + final length = await File(tempPath).length(); + if (length < 1024) { + _log.w('Temp M4A is too small (<1KB), skipping conversion'); + } else { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath != null) { + _log.d('Converted to FLAC (temp): $flacPath'); + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, @@ -4279,32 +4298,199 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - if (backendGenre != null || - backendLabel != null || - backendCopyright != null) { - _log.d( - 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', - ); - } - await _embedMetadataAndCover( flacPath, finalTrack, genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, + ); + + final newFileName = '${safBaseName ?? 'track'}.flac'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt('.flac'), + srcPath: flacPath, + ); + + if (newUri != null) { + if (newUri != currentFilePath) { + await _deleteSafFile(currentFilePath); + } + filePath = newUri; + finalSafFileName = newFileName; + } else { + _log.w('Failed to write FLAC to SAF, keeping M4A'); + } + } else { + _log.w( + 'FFmpeg conversion returned null, keeping M4A file', ); - _log.d('Metadata and cover embedded successfully'); - } catch (e) { - _log.w('Warning: Failed to embed metadata/cover: $e'); } - } else { - _log.w('FFmpeg conversion returned null, keeping M4A file'); + } + } catch (e) { + _log.w('SAF M4A->FLAC conversion failed: $e'); + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (flacPath != null) { + try { + await File(flacPath).delete(); + } catch (_) {} } } } - } catch (e) { - _log.w('FFmpeg conversion process failed: $e, keeping M4A file'); + } + } else { + if (quality == 'HIGH') { + final tidalHighFormat = settings.tidalHighFormat; + _log.i( + 'Tidal HIGH quality download, converting M4A to $tidalHighFormat...', + ); + + try { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + + final format = + tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final convertedPath = await FFmpegService.convertM4aToLossy( + currentFilePath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: true, + ); + + if (convertedPath != null) { + filePath = convertedPath; + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + actualQuality = '${format.toUpperCase()} $bitrateDisplay'; + _log.i( + 'Successfully converted M4A to $format: $convertedPath', + ); + + _log.i('Embedding metadata to $format...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (format == 'mp3') { + await _embedMetadataToMp3( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else { + await _embedMetadataToOpus( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } + _log.d('Metadata embedded successfully'); + } else { + _log.w('M4A to $format conversion failed, keeping M4A file'); + actualQuality = 'AAC 320kbps'; + } + } catch (e) { + _log.w('M4A conversion process failed: $e, keeping M4A file'); + actualQuality = 'AAC 320kbps'; + } + } else { + _log.d( + 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', + ); + + try { + final file = File(currentFilePath); + if (!await file.exists()) { + _log.e('File does not exist at path: $filePath'); + } else { + final length = await file.length(); + _log.i('File size before conversion: ${length / 1024} KB'); + + if (length < 1024) { + _log.w( + 'File is too small (<1KB), skipping conversion. Download might be corrupt.', + ); + } else { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + final flacPath = await FFmpegService.convertM4aToFlac( + currentFilePath, + ); + + if (flacPath != null) { + filePath = flacPath; + _log.d('Converted to FLAC: $flacPath'); + + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); + try { + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (backendGenre != null || + backendLabel != null || + backendCopyright != null) { + _log.d( + 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', + ); + } + + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + _log.d('Metadata and cover embedded successfully'); + } catch (e) { + _log.w('Warning: Failed to embed metadata/cover: $e'); + } + } else { + _log.w( + 'FFmpeg conversion returned null, keeping M4A file', + ); + } + } + } + } catch (e) { + _log.w( + 'FFmpeg conversion process failed: $e, keeping M4A file', + ); + } } } } else if (metadataEmbeddingEnabled && diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 44251a03..a32277e7 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -117,10 +117,6 @@ class SettingsNotifier extends Notifier { useCustomSpotifyCredentials: false, ); } - // Migration 6: Tidal HIGH quality removed — migrate to LOSSLESS - if (state.audioQuality == 'HIGH') { - state = state.copyWith(audioQuality: 'LOSSLESS'); - } state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); @@ -455,6 +451,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setTidalHighFormat(String format) { + state = state.copyWith(tidalHighFormat: format); + _saveSettings(); + } + void setYoutubeOpusBitrate(int bitrate) { final normalized = _normalizeYouTubeOpusBitrate(bitrate); state = state.copyWith(youtubeOpusBitrate: normalized); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 218871f0..d10851e2 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -301,6 +301,7 @@ class _DownloadSettingsPageState extends ConsumerState { final topPadding = normalizedHeaderTopPadding(context); final isBuiltInService = _builtInServices.contains(settings.defaultService); + final isTidalService = settings.defaultService == 'tidal'; return PopScope( canPop: true, @@ -408,8 +409,37 @@ class _DownloadSettingsPageState extends ConsumerState { onTap: () => ref .read(settingsProvider.notifier) .setAudioQuality('HI_RES_LOSSLESS'), - showDivider: false, + showDivider: isTidalService, ), + // Lossy 320kbps option (Tidal only) - downloads M4A AAC from server, converts to MP3/Opus + if (isTidalService) + _QualityOption( + title: context.l10n.downloadLossy320, + subtitle: _getTidalHighFormatLabel( + context, + settings.tidalHighFormat, + ), + isSelected: settings.audioQuality == 'HIGH', + onTap: () => ref + .read(settingsProvider.notifier) + .setAudioQuality('HIGH'), + showDivider: false, + ), + if (isTidalService && settings.audioQuality == 'HIGH') + SettingsItem( + icon: Icons.tune, + title: context.l10n.downloadLossyFormat, + subtitle: _getTidalHighFormatLabel( + context, + settings.tidalHighFormat, + ), + onTap: () => _showTidalHighFormatPicker( + context, + ref, + settings.tidalHighFormat, + ), + showDivider: false, + ), ], if (!isBuiltInService) ...[ Padding( @@ -1561,6 +1591,104 @@ class _DownloadSettingsPageState extends ConsumerState { return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); } + String _getTidalHighFormatLabel(BuildContext context, String format) { + switch (format) { + case 'mp3_320': + return context.l10n.downloadLossyMp3; + case 'opus_256': + return context.l10n.downloadLossyOpus256; + case 'opus_128': + return context.l10n.downloadLossyOpus128; + default: + return context.l10n.downloadLossyMp3; + } + } + + void _showTidalHighFormatPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.downloadLossy320Format, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.downloadLossy320FormatDesc, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: Text(context.l10n.downloadLossyMp3), + subtitle: Text(context.l10n.downloadLossyMp3Subtitle), + trailing: current == 'mp3_320' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('mp3_320'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: Text(context.l10n.downloadLossyOpus256), + subtitle: Text(context.l10n.downloadLossyOpus256Subtitle), + trailing: current == 'opus_256' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('opus_256'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: Text(context.l10n.downloadLossyOpus128), + subtitle: Text(context.l10n.downloadLossyOpus128Subtitle), + trailing: current == 'opus_128' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('opus_128'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + void _showYoutubeBitratePicker({ required BuildContext context, required String title,