From 98fdc0ed7ccb5a158e7bcc6c38937c06d20d04a0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 22 Mar 2026 23:31:47 +0700 Subject: [PATCH] feat: restore Tidal HIGH (AAC 320kbps) lossy quality option (closes #242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested by @okinaau in issue #242 — brings back the ability to download tracks in lossy format for users on low storage devices. HIGH quality fetches the AAC M4A stream directly from the Tidal server (no lossless download + re-encode), then converts to MP3 or Opus via FFmpeg based on the tidalHighFormat setting (mp3_320, opus_256, or opus_128). - go_backend/tidal.go: restore outputExt .m4a, filename logic, duplicate-check guard, HIGH M4A lyrics/LRC handling, and bitDepth=0/sampleRate=44100 for HIGH quality result - settings.dart + settings.g.dart: re-add tidalHighFormat field (default mp3_320) with JSON serialization - settings_provider.dart: re-add setTidalHighFormat(), remove migration that force-migrated HIGH to LOSSLESS - download_queue_provider.dart: restore HIGH conversion logic for both SAF and non-SAF paths using FFmpegService.convertM4aToLossy - download_settings_page.dart: restore Lossy 320kbps quality tile, format sub-picker tile, _getTidalHighFormatLabel helper, and _showTidalHighFormatPicker bottom sheet - l10n: add 10 keys (downloadLossy320, downloadLossyFormat, downloadLossy320Format, downloadLossy320FormatDesc, downloadLossyMp3, downloadLossyMp3Subtitle, downloadLossyOpus256/Subtitle, downloadLossyOpus128/Subtitle) to ARB and all 13 generated files --- go_backend/tidal.go | 40 +- lib/l10n/app_localizations.dart | 60 +++ lib/l10n/app_localizations_de.dart | 32 ++ lib/l10n/app_localizations_en.dart | 32 ++ lib/l10n/app_localizations_es.dart | 32 ++ lib/l10n/app_localizations_fr.dart | 32 ++ lib/l10n/app_localizations_hi.dart | 32 ++ lib/l10n/app_localizations_id.dart | 32 ++ lib/l10n/app_localizations_ja.dart | 32 ++ lib/l10n/app_localizations_ko.dart | 32 ++ lib/l10n/app_localizations_nl.dart | 32 ++ lib/l10n/app_localizations_pt.dart | 32 ++ lib/l10n/app_localizations_ru.dart | 32 ++ lib/l10n/app_localizations_tr.dart | 32 ++ lib/l10n/app_localizations_zh.dart | 32 ++ lib/l10n/arb/app_en.arb | 40 ++ lib/models/settings.dart | 5 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 370 +++++++++++++----- lib/providers/settings_provider.dart | 9 +- .../settings/download_settings_page.dart | 130 +++++- 21 files changed, 970 insertions(+), 102 deletions(-) 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,