mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
feat(tidal): convert M4A to MP3/Opus for HIGH quality, remove LOSSY option
- Add tidalHighFormat setting (mp3_320 or opus_128) for Tidal HIGH quality - Add convertM4aToLossy() in FFmpegService for M4A to MP3/Opus conversion - Remove inefficient LOSSY option (FLAC download then convert) - Update download_queue_provider to handle HIGH quality conversion - Clean up LOSSY references from download_service_picker and log messages - Update Go backend: amazon.go, tidal.go, metadata.go improvements - UI: minor updates to album, playlist, and home screens
This commit is contained in:
parent
ee212a0e48
commit
eb0cdbeba8
16 changed files with 288 additions and 749 deletions
3
android/app/proguard-rules.pro
vendored
3
android/app/proguard-rules.pro
vendored
|
|
@ -28,6 +28,9 @@
|
||||||
# FFmpeg Kit
|
# FFmpeg Kit
|
||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-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)
|
# Apache Tika (if used by FFmpeg)
|
||||||
-dontwarn org.apache.tika.**
|
-dontwarn org.apache.tika.**
|
||||||
|
|
|
||||||
|
|
@ -299,8 +299,13 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
|
actualDate := req.ReleaseDate
|
||||||
|
actualAlbum := req.AlbumName
|
||||||
|
actualTitle := req.TrackName
|
||||||
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
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) {
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.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
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.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{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: actualTitle,
|
||||||
Artist: req.ArtistName,
|
Artist: actualArtist,
|
||||||
Album: req.AlbumName,
|
Album: actualAlbum,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: actualDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
|
|
@ -327,11 +346,20 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
Copyright: req.Copyright,
|
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
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(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 {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,41 @@ func fileExists(path string) bool {
|
||||||
return err == nil
|
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 {
|
func EmbedLyrics(filePath string, lyrics string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -512,356 +547,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
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) {
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -974,52 +659,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
|
||||||
return atomHeader{}, false, nil
|
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) {
|
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||||
const chunkSize = 64 * 1024
|
const chunkSize = 64 * 1024
|
||||||
patternMP4A := []byte("mp4a")
|
patternMP4A := []byte("mp4a")
|
||||||
|
|
|
||||||
|
|
@ -122,16 +122,16 @@ func NewTidalDownloader() *TidalDownloader {
|
||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
// GetAvailableAPIs returns list of available Tidal APIs
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||||
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
|
||||||
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
|
||||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||||
|
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
||||||
|
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
|
|
@ -1678,29 +1678,18 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
} 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" {
|
if quality == "HIGH" {
|
||||||
GoLog("[Tidal] Embedding metadata to M4A file for HIGH quality...\n")
|
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle lyrics for M4A
|
// Only save external LRC file for lyrics
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
lyricsMode := req.LyricsMode
|
GoLog("[Tidal] Saving external LRC file for M4A...\n")
|
||||||
if lyricsMode == "" {
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
lyricsMode = "external" // Default to external for M4A since embedding is complex
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
}
|
} else {
|
||||||
|
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -2196,7 +2196,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||||
String get discographyNoAlbums => 'No albums available';
|
String get discographyNoAlbums => 'No albums available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
|
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionStorageAccess => 'Storage Access';
|
String get sectionStorageAccess => 'Storage Access';
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,8 @@ class AppSettings {
|
||||||
final String albumFolderStructure;
|
final String albumFolderStructure;
|
||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
final String locale;
|
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 lyricsMode;
|
||||||
|
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
|
||||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
|
|
@ -65,10 +63,8 @@ class AppSettings {
|
||||||
this.albumFolderStructure = 'artist_album',
|
this.albumFolderStructure = 'artist_album',
|
||||||
this.showExtensionStore = true,
|
this.showExtensionStore = true,
|
||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.enableLossyOption = false,
|
|
||||||
this.lossyFormat = 'mp3',
|
|
||||||
this.lossyBitrate = 'mp3_320',
|
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
|
this.tidalHighFormat = 'mp3_320',
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,10 +97,8 @@ class AppSettings {
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
String? locale,
|
String? locale,
|
||||||
bool? enableLossyOption,
|
|
||||||
String? lossyFormat,
|
|
||||||
String? lossyBitrate,
|
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
|
String? tidalHighFormat,
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
|
|
@ -135,10 +129,8 @@ class AppSettings {
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
|
|
||||||
lossyFormat: lossyFormat ?? this.lossyFormat,
|
|
||||||
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
|
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
locale: json['locale'] as String? ?? 'system',
|
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',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
|
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -72,9 +70,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'enableLossyOption': instance.enableLossyOption,
|
|
||||||
'lossyFormat': instance.lossyFormat,
|
|
||||||
'lossyBitrate': instance.lossyBitrate,
|
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1804,10 +1804,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
);
|
);
|
||||||
|
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
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
|
// Fetch extended metadata (genre, label) from Deezer if available
|
||||||
String? genre;
|
String? genre;
|
||||||
|
|
@ -1858,7 +1854,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
_log.d('Using extension providers for download');
|
_log.d('Using extension providers for download');
|
||||||
_log.d(
|
_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');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithExtensions(
|
result = await PlatformBridge.downloadWithExtensions(
|
||||||
|
|
@ -1871,7 +1867,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: downloadQuality,
|
quality: quality,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
|
|
@ -1885,7 +1881,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
} else if (state.autoFallback) {
|
} else if (state.autoFallback) {
|
||||||
_log.d('Using auto-fallback mode');
|
_log.d('Using auto-fallback mode');
|
||||||
_log.d(
|
_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');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
|
|
@ -1898,7 +1894,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: downloadQuality,
|
quality: quality,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
|
|
@ -1921,7 +1917,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: downloadQuality,
|
quality: quality,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
|
|
@ -1980,10 +1976,73 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
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') {
|
if (quality == 'HIGH') {
|
||||||
_log.i('Native AAC 320kbps download (HIGH quality), keeping M4A file');
|
final tidalHighFormat = settings.tidalHighFormat;
|
||||||
actualQuality = 'AAC 320kbps';
|
_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 {
|
} else {
|
||||||
_log.d(
|
_log.d(
|
||||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||||
|
|
@ -2112,74 +2171,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
return;
|
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(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.completed,
|
DownloadStatus.completed,
|
||||||
|
|
|
||||||
|
|
@ -231,24 +231,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setEnableLossyOption(bool enabled) {
|
void setTidalHighFormat(String format) {
|
||||||
state = state.copyWith(enableLossyOption: enabled);
|
state = state.copyWith(tidalHighFormat: format);
|
||||||
// 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);
|
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
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(
|
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||||
id: widget.albumId,
|
id: widget.albumId,
|
||||||
name: widget.albumName,
|
name: widget.albumName,
|
||||||
|
|
|
||||||
|
|
@ -1887,12 +1887,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
void _navigateToSearchAlbum(SearchAlbum album) {
|
void _navigateToSearchAlbum(SearchAlbum album) {
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
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(
|
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||||
id: album.id,
|
id: album.id,
|
||||||
name: album.name,
|
name: album.name,
|
||||||
|
|
@ -1901,9 +1895,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
providerId: 'deezer',
|
providerId: 'deezer',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => AlbumScreen(
|
builder: (context) => AlbumScreen(
|
||||||
albumId: albumId,
|
albumId: album.id,
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
coverUrl: album.imageUrl,
|
coverUrl: album.imageUrl,
|
||||||
tracks: const [], // Will be fetched by AlbumScreen
|
tracks: const [], // Will be fetched by AlbumScreen
|
||||||
|
|
@ -1914,12 +1909,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
|
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
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(
|
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
name: playlist.name,
|
name: playlist.name,
|
||||||
|
|
@ -1928,12 +1917,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
providerId: 'deezer',
|
providerId: 'deezer',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => PlaylistScreen(
|
builder: (context) => PlaylistScreen(
|
||||||
playlistName: playlist.name,
|
playlistName: playlist.name,
|
||||||
coverUrl: playlist.imageUrl,
|
coverUrl: playlist.imageUrl,
|
||||||
tracks: const [], // Will be fetched
|
tracks: const [], // Will be fetched
|
||||||
playlistId: playlistId,
|
playlistId: playlist.id,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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;
|
if (!mounted) return;
|
||||||
|
|
||||||
final trackList = result['tracks'] as List<dynamic>? ?? [];
|
// Go backend returns 'track_list' not 'tracks'
|
||||||
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -174,24 +174,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAskQualityBeforeDownload(value),
|
.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) ...[
|
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: context.l10n.qualityFlacLossless,
|
title: context.l10n.qualityFlacLossless,
|
||||||
|
|
@ -216,29 +198,25 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
.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)
|
if (isTidalService)
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'AAC 320kbps',
|
title: 'Lossy 320kbps',
|
||||||
subtitle: 'Native AAC (no conversion)',
|
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||||
isSelected: settings.audioQuality == 'HIGH',
|
isSelected: settings.audioQuality == 'HIGH',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HIGH'),
|
.setAudioQuality('HIGH'),
|
||||||
showDivider: settings.enableLossyOption,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
if (settings.enableLossyOption)
|
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||||
_QualityOption(
|
SettingsItem(
|
||||||
title: context.l10n.qualityLossy,
|
icon: Icons.tune,
|
||||||
subtitle: settings.lossyFormat == 'opus'
|
title: 'Lossy Format',
|
||||||
? context.l10n.qualityLossyOpusSubtitle
|
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||||
: context.l10n.qualityLossyMp3Subtitle,
|
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
|
||||||
isSelected: settings.audioQuality == 'LOSSY',
|
|
||||||
onTap: () => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setAudioQuality('LOSSY'),
|
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -870,28 +848,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getLossyBitrateLabel(String bitrate) {
|
String _getTidalHighFormatLabel(String format) {
|
||||||
switch (bitrate) {
|
switch (format) {
|
||||||
case 'mp3_320':
|
case 'mp3_320':
|
||||||
return 'MP3 320kbps (Best)';
|
return 'MP3 320kbps';
|
||||||
case 'mp3_256':
|
|
||||||
return 'MP3 256kbps';
|
|
||||||
case 'mp3_192':
|
|
||||||
return 'MP3 192kbps';
|
|
||||||
case 'mp3_128':
|
|
||||||
return 'MP3 128kbps';
|
|
||||||
case 'opus_128':
|
case 'opus_128':
|
||||||
return 'Opus 128kbps (Best)';
|
return 'Opus 128kbps';
|
||||||
case 'opus_96':
|
|
||||||
return 'Opus 96kbps';
|
|
||||||
case 'opus_64':
|
|
||||||
return 'Opus 64kbps';
|
|
||||||
default:
|
default:
|
||||||
return 'MP3 320kbps';
|
return 'MP3 320kbps';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLossyBitratePicker(
|
void _showTidalHighFormatPicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
String current,
|
String current,
|
||||||
|
|
@ -900,130 +868,54 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
),
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
child: Text(
|
||||||
child: Text(
|
'Lossy 320kbps Format',
|
||||||
context.l10n.lossyFormat,
|
style: Theme.of(
|
||||||
style: Theme.of(
|
context,
|
||||||
context,
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
).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),
|
ListTile(
|
||||||
child: Text(
|
leading: const Icon(Icons.audiotrack),
|
||||||
context.l10n.lossyFormatDescription,
|
title: const Text('MP3 320kbps'),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||||
color: colorScheme.onSurfaceVariant,
|
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||||
),
|
onTap: () {
|
||||||
),
|
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||||
),
|
Navigator.pop(context);
|
||||||
// MP3 Section
|
},
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
|
ListTile(
|
||||||
child: Text(
|
leading: const Icon(Icons.graphic_eq),
|
||||||
'MP3',
|
title: const Text('Opus 128kbps'),
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||||
color: colorScheme.primary,
|
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||||
fontWeight: FontWeight.bold,
|
onTap: () {
|
||||||
),
|
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||||
),
|
Navigator.pop(context);
|
||||||
),
|
},
|
||||||
ListTile(
|
),
|
||||||
leading: const Icon(Icons.audiotrack),
|
const SizedBox(height: 16),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,53 @@ class FFmpegService {
|
||||||
return null;
|
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<String?> 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<String?> convertFlacToMp3(
|
static Future<String?> convertFlacToMp3(
|
||||||
String inputPath, {
|
String inputPath, {
|
||||||
String bitrate = '320k',
|
String bitrate = '320k',
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const _builtInServices = [
|
||||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
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', 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: '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(
|
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)
|
/// A reusable widget for selecting download service (built-in + extensions)
|
||||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||||
final String? trackName;
|
final String? trackName;
|
||||||
|
|
@ -113,34 +106,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||||
|
|
||||||
/// Get quality options for the selected service
|
/// Get quality options for the selected service
|
||||||
List<QualityOption> _getQualityOptions() {
|
List<QualityOption> _getQualityOptions() {
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||||
if (builtIn != null) {
|
if (builtIn != null) {
|
||||||
// Add Lossy option if enabled in settings
|
|
||||||
if (settings.enableLossyOption) {
|
|
||||||
return [...builtIn.qualityOptions, _lossyQualityOption];
|
|
||||||
}
|
|
||||||
return builtIn.qualityOptions;
|
return builtIn.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||||
// Add Lossy option for extensions too if enabled
|
|
||||||
if (settings.enableLossyOption) {
|
|
||||||
return [...ext.qualityOptions, _lossyQualityOption];
|
|
||||||
}
|
|
||||||
return ext.qualityOptions;
|
return ext.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback options
|
// Default fallback options
|
||||||
final defaultOptions = [
|
return [
|
||||||
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||||
];
|
];
|
||||||
if (settings.enableLossyOption) {
|
|
||||||
return [...defaultOptions, _lossyQualityOption];
|
|
||||||
}
|
|
||||||
return defaultOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -262,7 +242,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||||
return Icons.aod;
|
return Icons.aod;
|
||||||
case 'MP3_320':
|
case 'MP3_320':
|
||||||
case 'MP3':
|
case 'MP3':
|
||||||
case 'LOSSY':
|
|
||||||
return Icons.audiotrack;
|
return Icons.audiotrack;
|
||||||
case 'OPUS':
|
case 'OPUS':
|
||||||
case 'OPUS_128':
|
case 'OPUS_128':
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue