diff --git a/android/.kotlin/sessions/kotlin-compiler-14855151662461671779.salive b/android/.kotlin/sessions/kotlin-compiler-14855151662461671779.salive deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 33baf88..2bd4f8d 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -28,6 +28,9 @@ # FFmpeg Kit -keep class com.arthenica.ffmpegkit.** { *; } -keep class com.arthenica.smartexception.** { *; } +# FFmpeg Kit (new fork package) +-keep class com.antonkarpenko.ffmpegkit.** { *; } +-keep class com.antonkarpenko.smartexception.** { *; } # Apache Tika (if used by FFmpeg) -dontwarn org.apache.tika.** diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 012d8c5..f4fb9ed 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -299,8 +299,13 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { existingMeta, metaErr := ReadMetadata(outputPath) actualTrackNum := req.TrackNumber actualDiscNum := req.DiscNumber + actualDate := req.ReleaseDate + actualAlbum := req.AlbumName + actualTitle := req.TrackName + actualArtist := req.ArtistName if metaErr == nil && existingMeta != nil { + // Use track/disc number from Amazon file if request has default values if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { actualTrackNum = existingMeta.TrackNumber GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) @@ -309,15 +314,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { actualDiscNum = existingMeta.DiscNumber GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) } + // Use release date from Amazon file if request doesn't have it + if existingMeta.Date != "" && req.ReleaseDate == "" { + actualDate = existingMeta.Date + GoLog("[Amazon] Using release date from file: %s\n", actualDate) + } + // Use album from Amazon file if request doesn't have it + if existingMeta.Album != "" && req.AlbumName == "" { + actualAlbum = existingMeta.Album + GoLog("[Amazon] Using album from file: %s\n", actualAlbum) + } + // Log existing metadata for debugging + GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n", + existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date) } - // Embed metadata using Spotify data + // Embed metadata using Spotify/Deezer data (preferred for consistency) + // but use Amazon file data as fallback for missing fields metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, + Title: actualTitle, + Artist: actualArtist, + Album: actualAlbum, AlbumArtist: req.AlbumArtist, - Date: req.ReleaseDate, + Date: actualDate, TrackNumber: actualTrackNum, TotalTracks: req.TotalTracks, DiscNumber: actualDiscNum, @@ -327,11 +346,20 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { Copyright: req.Copyright, } - // Use cover data from parallel fetch + // Use cover data from parallel fetch, or extract from Amazon file if not available var coverData []byte - if parallelResult != nil && parallelResult.CoverData != nil { + if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 { coverData = parallelResult.CoverData GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) + } else { + // Try to extract existing cover from Amazon file + existingCover, coverErr := ExtractCoverArt(outputPath) + if coverErr == nil && len(existingCover) > 0 { + coverData = existingCover + GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData)) + } else { + GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n") + } } if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 2cc9047..e636a80 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -336,6 +336,41 @@ func fileExists(path string) bool { return err == nil } +// ExtractCoverArt extracts cover art from a FLAC file +func ExtractCoverArt(filePath string) ([]byte, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + } + + for _, meta := range f.Meta { + if meta.Type == flac.Picture { + pic, err := flacpicture.ParseFromMetaDataBlock(*meta) + if err != nil { + continue + } + if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 { + return pic.ImageData, nil + } + } + } + + // If no front cover found, return any picture + for _, meta := range f.Meta { + if meta.Type == flac.Picture { + pic, err := flacpicture.ParseFromMetaDataBlock(*meta) + if err != nil { + continue + } + if len(pic.ImageData) > 0 { + return pic.ImageData, nil + } + } + } + + return nil, fmt.Errorf("no cover art found in file") +} + func EmbedLyrics(filePath string, lyrics string) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -512,356 +547,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") } -// ======================================== -// M4A (MP4/AAC) Metadata Embedding -// ======================================== - -// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms -func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error { - input, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("failed to open M4A file: %w", err) - } - defer input.Close() - - info, err := input.Stat() - if err != nil { - return fmt.Errorf("failed to stat M4A file: %w", err) - } - fileSize := info.Size() - - moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize) - if err != nil { - return fmt.Errorf("failed to find moov atom: %w", err) - } - if !moovFound { - return fmt.Errorf("moov atom not found in M4A file") - } - - moovContentStart := moovHeader.offset + moovHeader.headerSize - moovContentSize := moovHeader.size - moovHeader.headerSize - - udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize) - if err != nil { - return fmt.Errorf("failed to locate udta atom: %w", err) - } - - var metaHeader atomHeader - metaFound := false - if udtaFound { - udtaContentStart := udtaHeader.offset + udtaHeader.headerSize - udtaContentSize := udtaHeader.size - udtaHeader.headerSize - metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize) - if err != nil { - return fmt.Errorf("failed to locate meta atom: %w", err) - } - } - - metaAtom := buildMetaAtom(metadata, coverData) - metaSize := int64(len(metaAtom)) - - var delta int64 - var newUdtaSize int64 - switch { - case udtaFound && metaFound: - delta = metaSize - metaHeader.size - newUdtaSize = udtaHeader.size + delta - case udtaFound && !metaFound: - delta = metaSize - newUdtaSize = udtaHeader.size + delta - case !udtaFound: - newUdtaSize = int64(8 + len(metaAtom)) - delta = newUdtaSize - } - - newMoovSize := moovHeader.size + delta - if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) { - return fmt.Errorf("moov atom exceeds 32-bit size after update") - } - if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) { - return fmt.Errorf("udta atom exceeds 32-bit size after update") - } - if !udtaFound && newUdtaSize > int64(^uint32(0)) { - return fmt.Errorf("udta atom exceeds 32-bit size after update") - } - - tempPath := filePath + ".tmp" - output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - cleanupTemp := true - defer func() { - _ = output.Close() - if cleanupTemp { - _ = os.Remove(tempPath) - } - }() - - switch { - case udtaFound && metaFound: - if err := copyRange(output, input, 0, moovHeader.offset); err != nil { - return err - } - if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { - return err - } - if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil { - return err - } - if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil { - return err - } - if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil { - return err - } - if _, err := output.Write(metaAtom); err != nil { - return fmt.Errorf("failed to write meta atom: %w", err) - } - metaEnd := metaHeader.offset + metaHeader.size - if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil { - return err - } - case udtaFound && !metaFound: - if err := copyRange(output, input, 0, moovHeader.offset); err != nil { - return err - } - if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { - return err - } - if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil { - return err - } - if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil { - return err - } - insertPos := udtaHeader.offset + udtaHeader.size - if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil { - return err - } - if _, err := output.Write(metaAtom); err != nil { - return fmt.Errorf("failed to write meta atom: %w", err) - } - if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil { - return err - } - case !udtaFound: - newUdtaAtom := buildUdtaAtom(metaAtom) - if err := copyRange(output, input, 0, moovHeader.offset); err != nil { - return err - } - if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { - return err - } - moovEnd := moovHeader.offset + moovHeader.size - if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil { - return err - } - if _, err := output.Write(newUdtaAtom); err != nil { - return fmt.Errorf("failed to write udta atom: %w", err) - } - if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil { - return err - } - } - - if err := output.Close(); err != nil { - return fmt.Errorf("failed to close temp file: %w", err) - } - - _ = input.Close() - if err := os.Remove(filePath); err != nil { - return fmt.Errorf("failed to replace original file: %w", err) - } - if err := os.Rename(tempPath, filePath); err != nil { - return fmt.Errorf("failed to move temp file: %w", err) - } - cleanupTemp = false - - fmt.Printf("[M4A] Metadata embedded successfully\n") - return nil -} - -// buildMetaAtom builds a complete meta atom with ilst containing metadata -func buildMetaAtom(metadata Metadata, coverData []byte) []byte { - var ilst []byte - - if metadata.Title != "" { - ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...) - } - - if metadata.Artist != "" { - ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...) - } - - if metadata.Album != "" { - ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...) - } - - if metadata.AlbumArtist != "" { - ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...) - } - - if metadata.Date != "" { - ilst = append(ilst, buildTextAtom("©day", metadata.Date)...) - } - - if metadata.TrackNumber > 0 { - ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...) - } - - if metadata.DiscNumber > 0 { - ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...) - } - - if metadata.Lyrics != "" { - ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...) - } - - if len(coverData) > 0 { - ilst = append(ilst, buildCoverAtom(coverData)...) - } - - ilstSize := 8 + len(ilst) - ilstAtom := make([]byte, 4) - ilstAtom[0] = byte(ilstSize >> 24) - ilstAtom[1] = byte(ilstSize >> 16) - ilstAtom[2] = byte(ilstSize >> 8) - ilstAtom[3] = byte(ilstSize) - ilstAtom = append(ilstAtom, []byte("ilst")...) - ilstAtom = append(ilstAtom, ilst...) - - hdlr := []byte{ - 0, 0, 0, 33, // size = 33 - 'h', 'd', 'l', 'r', - 0, 0, 0, 0, // version + flags - 0, 0, 0, 0, // predefined - 'm', 'd', 'i', 'r', // handler type - 'a', 'p', 'p', 'l', // manufacturer - 0, 0, 0, 0, // component flags - 0, 0, 0, 0, // component flags mask - 0, // null terminator - } - - metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr - metaContent = append(metaContent, ilstAtom...) - - metaSize := 8 + len(metaContent) - metaAtom := make([]byte, 4) - metaAtom[0] = byte(metaSize >> 24) - metaAtom[1] = byte(metaSize >> 16) - metaAtom[2] = byte(metaSize >> 8) - metaAtom[3] = byte(metaSize) - metaAtom = append(metaAtom, []byte("meta")...) - metaAtom = append(metaAtom, metaContent...) - - return metaAtom -} - -func buildTextAtom(name, value string) []byte { - valueBytes := []byte(value) - - dataSize := 16 + len(valueBytes) - dataAtom := make([]byte, 4) - dataAtom[0] = byte(dataSize >> 24) - dataAtom[1] = byte(dataSize >> 16) - dataAtom[2] = byte(dataSize >> 8) - dataAtom[3] = byte(dataSize) - dataAtom = append(dataAtom, []byte("data")...) - dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8 - dataAtom = append(dataAtom, 0, 0, 0, 0) // locale - dataAtom = append(dataAtom, valueBytes...) - - atomSize := 8 + len(dataAtom) - atom := make([]byte, 4) - atom[0] = byte(atomSize >> 24) - atom[1] = byte(atomSize >> 16) - atom[2] = byte(atomSize >> 8) - atom[3] = byte(atomSize) - atom = append(atom, []byte(name)...) - atom = append(atom, dataAtom...) - - return atom -} - -// buildTrackNumberAtom builds trkn atom -func buildTrackNumberAtom(track, total int) []byte { - dataAtom := []byte{ - 0, 0, 0, 24, // size - 'd', 'a', 't', 'a', - 0, 0, 0, 0, // type = implicit - 0, 0, 0, 0, // locale - 0, 0, // padding - byte(track >> 8), byte(track), // track number - byte(total >> 8), byte(total), // total tracks - 0, 0, // padding - } - - atomSize := 8 + len(dataAtom) - atom := make([]byte, 4) - atom[0] = byte(atomSize >> 24) - atom[1] = byte(atomSize >> 16) - atom[2] = byte(atomSize >> 8) - atom[3] = byte(atomSize) - atom = append(atom, []byte("trkn")...) - atom = append(atom, dataAtom...) - - return atom -} - -func buildDiscNumberAtom(disc, total int) []byte { - dataAtom := []byte{ - 0, 0, 0, 22, // size - 'd', 'a', 't', 'a', - 0, 0, 0, 0, // type = implicit - 0, 0, 0, 0, // locale - 0, 0, // padding - byte(disc >> 8), byte(disc), // disc number - byte(total >> 8), byte(total), // total discs - } - - atomSize := 8 + len(dataAtom) - atom := make([]byte, 4) - atom[0] = byte(atomSize >> 24) - atom[1] = byte(atomSize >> 16) - atom[2] = byte(atomSize >> 8) - atom[3] = byte(atomSize) - atom = append(atom, []byte("disk")...) - atom = append(atom, dataAtom...) - - return atom -} - -// buildCoverAtom builds covr atom with image data -func buildCoverAtom(coverData []byte) []byte { - imageType := byte(13) - if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { - imageType = 14 - } - - dataSize := 16 + len(coverData) - dataAtom := make([]byte, 4) - dataAtom[0] = byte(dataSize >> 24) - dataAtom[1] = byte(dataSize >> 16) - dataAtom[2] = byte(dataSize >> 8) - dataAtom[3] = byte(dataSize) - dataAtom = append(dataAtom, []byte("data")...) - dataAtom = append(dataAtom, 0, 0, 0, imageType) - dataAtom = append(dataAtom, 0, 0, 0, 0) - dataAtom = append(dataAtom, coverData...) - - atomSize := 8 + len(dataAtom) - atom := make([]byte, 4) - atom[0] = byte(atomSize >> 24) - atom[1] = byte(atomSize >> 16) - atom[2] = byte(atomSize >> 8) - atom[3] = byte(atomSize) - atom = append(atom, []byte("covr")...) - atom = append(atom, dataAtom...) - - return atom -} - func GetM4AQuality(filePath string) (AudioQuality, error) { f, err := os.Open(filePath) if err != nil { @@ -974,52 +659,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6 return atomHeader{}, false, nil } -func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error { - if len(typ) != 4 { - return fmt.Errorf("invalid atom type: %s", typ) - } - - if headerSize == 16 { - header := make([]byte, 16) - binary.BigEndian.PutUint32(header[0:4], 1) - copy(header[4:8], []byte(typ)) - binary.BigEndian.PutUint64(header[8:16], uint64(size)) - _, err := w.Write(header) - return err - } - - if size > int64(^uint32(0)) { - return fmt.Errorf("atom size exceeds 32-bit for %s", typ) - } - - header := make([]byte, 8) - binary.BigEndian.PutUint32(header[0:4], uint32(size)) - copy(header[4:8], []byte(typ)) - _, err := w.Write(header) - return err -} - -func copyRange(dst io.Writer, src *os.File, offset, length int64) error { - if length <= 0 { - return nil - } - if _, err := src.Seek(offset, io.SeekStart); err != nil { - return fmt.Errorf("failed to seek source: %w", err) - } - if _, err := io.CopyN(dst, src, length); err != nil { - return fmt.Errorf("failed to copy data: %w", err) - } - return nil -} - -func buildUdtaAtom(metaAtom []byte) []byte { - size := 8 + len(metaAtom) - header := make([]byte, 8) - binary.BigEndian.PutUint32(header[0:4], uint32(size)) - copy(header[4:8], []byte("udta")) - return append(header, metaAtom...) -} - func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { const chunkSize = 64 * 1024 patternMP4A := []byte("mp4a") diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 5080919..598b458 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -122,16 +122,16 @@ func NewTidalDownloader() *TidalDownloader { // GetAvailableAPIs returns list of available Tidal APIs func (t *TidalDownloader) GetAvailableAPIs() []string { encodedAPIs := []string{ + "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority) "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf - "aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net - "aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site + "aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net + "aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net } var apis []string @@ -1678,29 +1678,18 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if strings.HasSuffix(actualOutputPath, ".m4a") { - // For HIGH quality (AAC 320kbps), embed metadata directly to M4A + // For HIGH quality (AAC 320kbps), skip metadata embedding as it can corrupt the file + // The M4A from Tidal server already has basic metadata if quality == "HIGH" { - GoLog("[Tidal] Embedding metadata to M4A file for HIGH quality...\n") - if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil { - GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err) - } else { - GoLog("[Tidal] M4A metadata embedded successfully\n") - } + GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - // Handle lyrics for M4A + // Only save external LRC file for lyrics if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "external" // Default to external for M4A since embedding is complex - } - - if lyricsMode == "external" || lyricsMode == "both" { - GoLog("[Tidal] Saving external LRC file for M4A...\n") - 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) - } + GoLog("[Tidal] Saving external LRC file for M4A...\n") + 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 { diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 018db30..336d24f 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2196,7 +2196,7 @@ class AppLocalizationsId extends AppLocalizations { String get discographyNoAlbums => 'No albums available'; @override - String get discographyFailedToFetch => 'Gagal mengambil beberapa album'; + String get discographyFailedToFetch => 'Failed to fetch some albums'; @override String get sectionStorageAccess => 'Storage Access'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 93b44cf..9da9871 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -31,10 +31,8 @@ class AppSettings { final String albumFolderStructure; final bool showExtensionStore; final String locale; - final bool enableLossyOption; - final String lossyFormat; - final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64' final String lyricsMode; + final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128' final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE const AppSettings({ @@ -65,10 +63,8 @@ class AppSettings { this.albumFolderStructure = 'artist_album', this.showExtensionStore = true, this.locale = 'system', - this.enableLossyOption = false, - this.lossyFormat = 'mp3', - this.lossyBitrate = 'mp3_320', this.lyricsMode = 'embed', + this.tidalHighFormat = 'mp3_320', this.useAllFilesAccess = false, }); @@ -101,10 +97,8 @@ class AppSettings { String? albumFolderStructure, bool? showExtensionStore, String? locale, - bool? enableLossyOption, - String? lossyFormat, - String? lossyBitrate, String? lyricsMode, + String? tidalHighFormat, bool? useAllFilesAccess, }) { return AppSettings( @@ -135,10 +129,8 @@ class AppSettings { albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, locale: locale ?? this.locale, - enableLossyOption: enableLossyOption ?? this.enableLossyOption, - lossyFormat: lossyFormat ?? this.lossyFormat, - lossyBitrate: lossyBitrate ?? this.lossyBitrate, lyricsMode: lyricsMode ?? this.lyricsMode, + tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 327eec5..cefd0ca 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -36,10 +36,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['albumFolderStructure'] as String? ?? 'artist_album', showExtensionStore: json['showExtensionStore'] as bool? ?? true, locale: json['locale'] as String? ?? 'system', - enableLossyOption: json['enableLossyOption'] as bool? ?? false, - lossyFormat: json['lossyFormat'] as String? ?? 'mp3', - lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320', lyricsMode: json['lyricsMode'] as String? ?? 'embed', + tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, ); @@ -72,9 +70,7 @@ Map _$AppSettingsToJson(AppSettings instance) => 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, 'locale': instance.locale, - 'enableLossyOption': instance.enableLossyOption, - 'lossyFormat': instance.lossyFormat, - 'lossyBitrate': instance.lossyBitrate, 'lyricsMode': instance.lyricsMode, + 'tidalHighFormat': instance.tidalHighFormat, 'useAllFilesAccess': instance.useAllFilesAccess, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index cfda063..773fea4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1804,10 +1804,6 @@ class DownloadQueueNotifier extends Notifier { ); final quality = item.qualityOverride ?? state.audioQuality; - - // For LOSSY, we need to download FLAC first then convert - // Servers don't support lossy quality directly - final downloadQuality = quality == 'LOSSY' ? 'LOSSLESS' : quality; // Fetch extended metadata (genre, label) from Deezer if available String? genre; @@ -1858,7 +1854,7 @@ class DownloadQueueNotifier extends Notifier { if (useExtensions) { _log.d('Using extension providers for download'); _log.d( - 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}', + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); _log.d('Output dir: $outputDir'); result = await PlatformBridge.downloadWithExtensions( @@ -1871,7 +1867,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: trackToDownload.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, - quality: downloadQuality, + quality: quality, trackNumber: trackToDownload.trackNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, @@ -1885,7 +1881,7 @@ class DownloadQueueNotifier extends Notifier { } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); _log.d( - 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}', + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); _log.d('Output dir: $outputDir'); result = await PlatformBridge.downloadWithFallback( @@ -1898,7 +1894,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: trackToDownload.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, - quality: downloadQuality, + quality: quality, trackNumber: trackToDownload.trackNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, @@ -1921,7 +1917,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: trackToDownload.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, - quality: downloadQuality, + quality: quality, trackNumber: trackToDownload.trackNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, @@ -1980,10 +1976,73 @@ class DownloadQueueNotifier extends Notifier { } if (filePath != null && filePath.endsWith('.m4a')) { - // For HIGH quality (native AAC 320kbps), skip M4A to FLAC conversion + // For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus if (quality == 'HIGH') { - _log.i('Native AAC 320kbps download (HIGH quality), keeping M4A file'); - actualQuality = 'AAC 320kbps'; + final tidalHighFormat = settings.tidalHighFormat; + _log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...'); + + try { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + + // Convert M4A to the selected format + final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final convertedPath = await FFmpegService.convertM4aToLossy( + filePath, + 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'); + + // Embed metadata + _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...', @@ -2112,74 +2171,6 @@ class DownloadQueueNotifier extends Notifier { return; } - if (quality == 'LOSSY' && filePath != null && filePath.endsWith('.flac')) { - if (wasExisting) { - _log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file'); - } else { - final lossyFormat = settings.lossyFormat; - final lossyBitrate = settings.lossyBitrate; - _log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.97, - ); - - try { - final convertedPath = await FFmpegService.convertFlacToLossy( - filePath, - format: lossyFormat, - bitrate: lossyBitrate, - deleteOriginal: true, - ); - - if (convertedPath != null) { - filePath = convertedPath; - // Extract bitrate for display (e.g., 'mp3_320' -> '320kbps') - final bitrateDisplay = lossyBitrate.contains('_') - ? '${lossyBitrate.split('_').last}kbps' - : (lossyFormat == 'opus' ? '128kbps' : '320kbps'); - actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay'; - _log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath'); - - // Embed metadata and cover for both MP3 and Opus - _log.i('Embedding metadata to $lossyFormat...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.99, - ); - - final lossyBackendGenre = result['genre'] as String?; - final lossyBackendLabel = result['label'] as String?; - final lossyBackendCopyright = result['copyright'] as String?; - - if (lossyFormat == 'mp3') { - await _embedMetadataToMp3( - convertedPath, - trackToDownload, - genre: lossyBackendGenre ?? genre, - label: lossyBackendLabel ?? label, - copyright: lossyBackendCopyright, - ); - } else if (lossyFormat == 'opus') { - await _embedMetadataToOpus( - convertedPath, - trackToDownload, - genre: lossyBackendGenre ?? genre, - label: lossyBackendLabel ?? label, - copyright: lossyBackendCopyright, - ); - } - } else { - _log.w('$lossyFormat conversion failed, keeping FLAC file'); - } - } catch (e) { - _log.e('Lossy conversion error: $e, keeping FLAC file'); - } - } - } - updateItemStatus( item.id, DownloadStatus.completed, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 93b60f6..cebf0dd 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -231,24 +231,8 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setEnableLossyOption(bool enabled) { - state = state.copyWith(enableLossyOption: enabled); - // If Lossy is disabled and current quality is LOSSY, reset to LOSSLESS - if (!enabled && state.audioQuality == 'LOSSY') { - state = state.copyWith(audioQuality: 'LOSSLESS'); - } - _saveSettings(); - } - - void setLossyFormat(String format) { - state = state.copyWith(lossyFormat: format); - _saveSettings(); - } - - void setLossyBitrate(String bitrate) { - // Extract format from bitrate (e.g., 'mp3_320' -> 'mp3') - final format = bitrate.split('_').first; - state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format); + void setTidalHighFormat(String format) { + state = state.copyWith(tidalHighFormat: format); _saveSettings(); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f2d176d..28f0ab6 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -80,7 +80,9 @@ class _AlbumScreenState extends ConsumerState { _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; + // Use extensionId if available, otherwise detect from albumId prefix + final providerId = widget.extensionId ?? + (widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'); ref.read(recentAccessProvider.notifier).recordAlbumAccess( id: widget.albumId, name: widget.albumName, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 3f5b029..0ce72f7 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1887,12 +1887,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToSearchAlbum(SearchAlbum album) { ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Extract the numeric ID from "deezer:123" format - String albumId = album.id; - if (albumId.startsWith('deezer:')) { - albumId = albumId.substring(7); - } - ref.read(recentAccessProvider.notifier).recordAlbumAccess( id: album.id, name: album.name, @@ -1901,9 +1895,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: 'deezer', ); + // Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source Navigator.push(context, MaterialPageRoute( builder: (context) => AlbumScreen( - albumId: albumId, + albumId: album.id, albumName: album.name, coverUrl: album.imageUrl, tracks: const [], // Will be fetched by AlbumScreen @@ -1914,12 +1909,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToSearchPlaylist(SearchPlaylist playlist) { ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Extract the numeric ID from "deezer:123" format - String playlistId = playlist.id; - if (playlistId.startsWith('deezer:')) { - playlistId = playlistId.substring(7); - } - ref.read(recentAccessProvider.notifier).recordPlaylistAccess( id: playlist.id, name: playlist.name, @@ -1928,12 +1917,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: 'deezer', ); + // Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source Navigator.push(context, MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: playlist.name, coverUrl: playlist.imageUrl, tracks: const [], // Will be fetched - playlistId: playlistId, + playlistId: playlist.id, ), )); } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index bf87e86..d319811 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -64,10 +64,17 @@ class _PlaylistScreenState extends ConsumerState { }); try { - final result = await PlatformBridge.getDeezerMetadata('playlist', widget.playlistId!); + // Extract numeric ID from "deezer:123" format + String playlistId = widget.playlistId!; + if (playlistId.startsWith('deezer:')) { + playlistId = playlistId.substring(7); + } + + final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); if (!mounted) return; - final trackList = result['tracks'] as List? ?? []; + // Go backend returns 'track_list' not 'tracks' + final trackList = result['track_list'] as List? ?? []; final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); setState(() { diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 8d14bf9..ab4c428 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -174,24 +174,6 @@ class _DownloadSettingsPageState extends ConsumerState { .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), ), - SettingsSwitchItem( - icon: Icons.audiotrack, - title: context.l10n.enableLossyOption, - subtitle: settings.enableLossyOption - ? context.l10n.enableLossyOptionSubtitleOn - : context.l10n.enableLossyOptionSubtitleOff, - value: settings.enableLossyOption, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setEnableLossyOption(value), - ), - if (settings.enableLossyOption) - SettingsItem( - icon: Icons.tune, - title: context.l10n.lossyFormat, - subtitle: _getLossyBitrateLabel(settings.lossyBitrate), - onTap: () => _showLossyBitratePicker(context, ref, settings.lossyBitrate), - ), if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ _QualityOption( title: context.l10n.qualityFlacLossless, @@ -216,29 +198,25 @@ class _DownloadSettingsPageState extends ConsumerState { onTap: () => ref .read(settingsProvider.notifier) .setAudioQuality('HI_RES_LOSSLESS'), - showDivider: isTidalService || settings.enableLossyOption, + showDivider: isTidalService, ), - // Native AAC 320kbps option (Tidal only) + // Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus if (isTidalService) _QualityOption( - title: 'AAC 320kbps', - subtitle: 'Native AAC (no conversion)', + title: 'Lossy 320kbps', + subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat), isSelected: settings.audioQuality == 'HIGH', onTap: () => ref .read(settingsProvider.notifier) .setAudioQuality('HIGH'), - showDivider: settings.enableLossyOption, + showDivider: false, ), - if (settings.enableLossyOption) - _QualityOption( - title: context.l10n.qualityLossy, - subtitle: settings.lossyFormat == 'opus' - ? context.l10n.qualityLossyOpusSubtitle - : context.l10n.qualityLossyMp3Subtitle, - isSelected: settings.audioQuality == 'LOSSY', - onTap: () => ref - .read(settingsProvider.notifier) - .setAudioQuality('LOSSY'), + if (isTidalService && settings.audioQuality == 'HIGH') + SettingsItem( + icon: Icons.tune, + title: 'Lossy Format', + subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat), + onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat), showDivider: false, ), ], @@ -870,28 +848,18 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - String _getLossyBitrateLabel(String bitrate) { - switch (bitrate) { + String _getTidalHighFormatLabel(String format) { + switch (format) { case 'mp3_320': - return 'MP3 320kbps (Best)'; - case 'mp3_256': - return 'MP3 256kbps'; - case 'mp3_192': - return 'MP3 192kbps'; - case 'mp3_128': - return 'MP3 128kbps'; + return 'MP3 320kbps'; case 'opus_128': - return 'Opus 128kbps (Best)'; - case 'opus_96': - return 'Opus 96kbps'; - case 'opus_64': - return 'Opus 64kbps'; + return 'Opus 128kbps'; default: return 'MP3 320kbps'; } } - void _showLossyBitratePicker( + void _showTidalHighFormatPicker( BuildContext context, WidgetRef ref, String current, @@ -900,130 +868,54 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, - isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), builder: (context) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.lossyFormat, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Lossy 320kbps Format', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.lossyFormatDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - // MP3 Section - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), - child: Text( - 'MP3', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: const Text('320kbps'), - subtitle: const Text('Best quality, larger files'), - trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('mp3_320'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: const Text('256kbps'), - subtitle: const Text('High quality'), - trailing: current == 'mp3_256' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('mp3_256'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: const Text('192kbps'), - subtitle: const Text('Good quality'), - trailing: current == 'mp3_192' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('mp3_192'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: const Text('128kbps'), - subtitle: const Text('Smaller files'), - trailing: current == 'mp3_128' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('mp3_128'); - Navigator.pop(context); - }, - ), - const Divider(indent: 24, endIndent: 24), - // Opus Section - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), - child: Text( - 'Opus', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: const Text('128kbps'), - subtitle: const Text('Best quality, efficient codec'), - trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('opus_128'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: const Text('96kbps'), - subtitle: const Text('Good quality'), - trailing: current == 'opus_96' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('opus_96'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: const Text('64kbps'), - subtitle: const Text('Smallest files'), - trailing: current == 'opus_64' ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setLossyBitrate('opus_64'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], - ), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: const Text('MP3 320kbps'), + subtitle: const Text('Best compatibility, ~10MB per track'), + 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: const Text('Opus 128kbps'), + subtitle: const Text('Modern codec, ~4MB per track'), + 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), + ], ), ), ); diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 9298dba..6b7ad4f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -48,6 +48,53 @@ class FFmpegService { return null; } + /// Convert M4A (AAC) to lossy format (MP3 or Opus) + /// format: 'mp3' or 'opus' + /// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value + static Future convertM4aToLossy( + String inputPath, { + required String format, + String? bitrate, + bool deleteOriginal = true, + }) async { + // Extract bitrate value from format like 'mp3_320' -> '320k' + String bitrateValue = format == 'opus' ? '128k' : '320k'; + if (bitrate != null && bitrate.contains('_')) { + final parts = bitrate.split('_'); + if (parts.length == 2) { + bitrateValue = '${parts[1]}k'; + } + } + + final extension = format == 'opus' ? '.opus' : '.mp3'; + final outputPath = inputPath.replaceAll('.m4a', extension); + + String command; + if (format == 'opus') { + // M4A -> Opus conversion + command = + '-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y'; + } else { + // M4A -> MP3 conversion + command = + '-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y'; + } + + final result = await _execute(command); + + if (result.success) { + if (deleteOriginal) { + try { + await File(inputPath).delete(); + } catch (_) {} + } + return outputPath; + } + + _log.e('M4A to $format conversion failed: ${result.output}'); + return null; + } + static Future convertFlacToMp3( String inputPath, { String bitrate = '320k', diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index c7e5b17..ef48599 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -27,7 +27,7 @@ const _builtInServices = [ QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), - QualityOption(id: 'HIGH', label: 'AAC 320kbps', description: 'Native AAC (no conversion)'), + QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'), ], ), BuiltInService( @@ -50,13 +50,6 @@ const _builtInServices = [ ), ]; -/// Lossy quality option (shown when enabled in settings) -const _lossyQualityOption = QualityOption( - id: 'LOSSY', - label: 'Lossy', - description: 'MP3 320kbps or Opus 128kbps', -); - /// A reusable widget for selecting download service (built-in + extensions) class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; @@ -113,34 +106,21 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { - final settings = ref.read(settingsProvider); final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; if (builtIn != null) { - // Add Lossy option if enabled in settings - if (settings.enableLossyOption) { - return [...builtIn.qualityOptions, _lossyQualityOption]; - } return builtIn.qualityOptions; } final extensionState = ref.read(extensionProvider); final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; if (ext != null && ext.qualityOptions.isNotEmpty) { - // Add Lossy option for extensions too if enabled - if (settings.enableLossyOption) { - return [...ext.qualityOptions, _lossyQualityOption]; - } return ext.qualityOptions; } // Default fallback options - final defaultOptions = [ + return [ const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), ]; - if (settings.enableLossyOption) { - return [...defaultOptions, _lossyQualityOption]; - } - return defaultOptions; } @override @@ -262,7 +242,6 @@ class _DownloadServicePickerState extends ConsumerState { return Icons.aod; case 'MP3_320': case 'MP3': - case 'LOSSY': return Icons.audiotrack; case 'OPUS': case 'OPUS_128':