feat(audio): add WAV and AIFF support + settings-style metadata menu

WAV/AIFF: library scan, quality probe, native tag read/write via embedded ID3 chunk (RIFF id3 / AIFF ID3), cover art, ReadFileMetadata, ExtractLyrics, and FLAC<->WAV/AIFF conversion (PCM, bit-depth preserved via ffprobe). Treat WAV/AIFF as lossless across all convert sheets (no bitrate picker, Lossless labels) via isLosslessConversionTarget. Native MIME maps for SAF. Redesign the track metadata three-dot menu to a settings-style grouped card with a single divider above Share.
This commit is contained in:
zarzet
2026-06-12 21:10:37 +07:00
parent 2a2e2924eb
commit b8b670642c
17 changed files with 1481 additions and 172 deletions
@@ -307,6 +307,8 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
@@ -791,6 +793,8 @@ class MainActivity: FlutterFragmentActivity() {
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
"audio/aiff", "audio/x-aiff" -> ".aiff"
else -> ""
}
}
+3
View File
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
+1 -1
View File
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
+67
View File
@@ -1160,6 +1160,8 @@ func ReadFileMetadata(filePath string) (string, error) {
isApe := strings.HasSuffix(lower, ".ape")
isWv := strings.HasSuffix(lower, ".wv")
isMpc := strings.HasSuffix(lower, ".mpc")
isWav := strings.HasSuffix(lower, ".wav")
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
result := map[string]interface{}{
"title": "",
@@ -1406,6 +1408,51 @@ func ReadFileMetadata(filePath string) (string, error) {
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
}
} else if isWav || isAiff {
var meta *AudioMetadata
var quality *WAVQuality
var qualityErr error
if isAiff {
result["format"] = "aiff"
result["audio_codec"] = "pcm"
meta, _ = ReadAIFFTags(filePath)
quality, qualityErr = GetAIFFQuality(filePath)
} else {
result["format"] = "wav"
result["audio_codec"] = "pcm"
meta, _ = ReadWAVTags(filePath)
quality, qualityErr = GetWAVQuality(filePath)
}
if meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["total_tracks"] = meta.TotalTracks
result["disc_number"] = meta.DiscNumber
result["total_discs"] = meta.TotalDiscs
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
if qualityErr == nil && quality != nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
}
@@ -1474,6 +1521,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
isWavFile := strings.HasSuffix(lower, ".wav")
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
@@ -1502,6 +1551,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
if isWavFile {
if err := WriteWAVTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
}
resp := map[string]any{"success": true, "method": "native_wav"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isAiffFile {
if err := WriteAIFFTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
}
resp := map[string]any{"success": true, "method": "native_aiff"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// APE/WV/MPC: write APEv2 tags natively
if isApeFile {
trackNum := 0
+8 -1
View File
@@ -76,6 +76,9 @@ var supportedAudioFormats = map[string]bool{
".ape": true,
".wv": true,
".mpc": true,
".wav": true,
".aiff": true,
".aif": true,
".cue": true,
}
@@ -340,6 +343,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
case ".wav":
return scanWAVFile(filePath, result, displayNameHint)
case ".aiff", ".aif", ".aifc":
return scanAIFFFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
@@ -479,7 +486,7 @@ func libraryFormatForM4ACodec(codec string) string {
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac":
case "flac", "alac", "wav", "aiff", "aif", "aifc":
return true
default:
return false
+26
View File
@@ -906,6 +906,32 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".wav") {
meta, err := ReadWAVTags(filePath)
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") {
meta, err := ReadAIFFTags(filePath)
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
return extractLyricsFromSidecarLRC(filePath)
}
+975
View File
@@ -0,0 +1,975 @@
package gobackend
// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and
// cover-art extraction. These containers are not handled by go-flac, so chunks
// are parsed/written by hand here.
//
// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase
// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because
// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe
// frame sizes and UTF-8 text, so anything we write is read back losslessly.
//
// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files
// that carry only RIFF INFO tags (common from other taggers).
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes.
type WAVQuality struct {
SampleRate int
BitDepth int
Channels int
Duration int
}
const (
wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk
id3ChunkWAV = "id3 "
id3ChunkAIFF = "ID3 "
wavFormatPCM = 0x0001
wavFormatFloat = 0x0003
wavFormatExtensn = 0xFFFE
)
// ---------- low-level chunk size helpers ----------
func putUint32(dst []byte, le bool, v uint32) {
if le {
binary.LittleEndian.PutUint32(dst, v)
} else {
binary.BigEndian.PutUint32(dst, v)
}
}
func readUint32(b []byte, le bool) uint32 {
if le {
return binary.LittleEndian.Uint32(b)
}
return binary.BigEndian.Uint32(b)
}
func synchsafeEncode(n int) []byte {
return []byte{
byte((n >> 21) & 0x7f),
byte((n >> 14) & 0x7f),
byte((n >> 7) & 0x7f),
byte(n & 0x7f),
}
}
func synchsafeDecode(b []byte) int {
if len(b) < 4 {
return 0
}
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
}
// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the
// AIFF COMM chunk for the sample rate).
func parseExtendedFloat80(b []byte) float64 {
if len(b) < 10 {
return 0
}
sign := 1.0
if b[0]&0x80 != 0 {
sign = -1.0
}
exponent := int(b[0]&0x7f)<<8 | int(b[1])
var mantissa uint64
for i := 2; i < 10; i++ {
mantissa = mantissa<<8 | uint64(b[i])
}
if exponent == 0 && mantissa == 0 {
return 0
}
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
}
// ---------- WAV (RIFF) ----------
type wavProbe struct {
sampleRate int
bitDepth int
channels int
byteRate int
dataSize int64
id3 []byte
info map[string]string
}
// streamProbeWAV walks the top-level RIFF chunks, buffering only the small
// metadata chunks (fmt/id3/LIST) and skipping the large data chunk.
func streamProbeWAV(f *os.File) (*wavProbe, error) {
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return nil, err
}
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return nil, fmt.Errorf("not a WAVE file")
}
p := &wavProbe{info: map[string]string{}}
hdr := make([]byte, 8)
for {
if _, err := io.ReadFull(f, hdr); err != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], true)
pad := int64(size) & 1
switch id {
case "fmt ":
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err != nil {
return p, nil
}
if len(buf) >= 16 {
format := binary.LittleEndian.Uint16(buf[0:2])
p.channels = int(binary.LittleEndian.Uint16(buf[2:4]))
p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8]))
p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12]))
p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16]))
if format == wavFormatExtensn && len(buf) >= 26 {
// Valid bits per sample lives in the extension; the real
// PCM format tag is in the GUID, but bitDepth from the
// container field is sufficient for display.
if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 {
p.bitDepth = vb
}
}
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
case "data":
p.dataSize = int64(size)
f.Seek(int64(size)+pad, io.SeekCurrent)
case id3ChunkWAV, "ID3 ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
p.id3 = buf
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
case "LIST":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
parseRIFFInfo(buf, p.info)
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
default:
f.Seek(int64(size)+pad, io.SeekCurrent)
}
}
return p, nil
}
// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART).
func parseRIFFInfo(buf []byte, out map[string]string) {
if len(buf) < 4 || string(buf[0:4]) != "INFO" {
return
}
pos := 4
for pos+8 <= len(buf) {
id := string(buf[pos : pos+4])
size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8]))
pos += 8
if size <= 0 || pos+size > len(buf) {
break
}
val := strings.TrimRight(string(buf[pos:pos+size]), "\x00")
out[id] = strings.TrimSpace(val)
pos += size
if size&1 == 1 {
pos++
}
}
}
func wavMetadataFromProbe(p *wavProbe) *AudioMetadata {
if p == nil {
return nil
}
if len(p.id3) > 0 {
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
return meta
}
}
if len(p.info) > 0 {
meta := &AudioMetadata{
Title: p.info["INAM"],
Artist: p.info["IART"],
Album: p.info["IPRD"],
Genre: cleanGenre(p.info["IGNR"]),
Date: p.info["ICRD"],
Comment: p.info["ICMT"],
Copyright: p.info["ICOP"],
Composer: p.info["IMUS"],
}
if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil {
meta.TrackNumber = n
}
if meta.Date != "" && len(meta.Date) >= 4 {
meta.Year = meta.Date[:4]
}
if meta.Title != "" || meta.Artist != "" || meta.Album != "" {
return meta
}
}
return nil
}
// GetWAVQuality probes PCM parameters and computes duration from the data size.
func GetWAVQuality(filePath string) (*WAVQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeWAV(f)
if err != nil {
return nil, err
}
q := &WAVQuality{
SampleRate: p.sampleRate,
BitDepth: p.bitDepth,
Channels: p.channels,
}
if p.byteRate > 0 && p.dataSize > 0 {
q.Duration = int(p.dataSize / int64(p.byteRate))
} else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 {
bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8)
if bytesPerSec > 0 {
q.Duration = int(p.dataSize / bytesPerSec)
}
}
return q, nil
}
// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback).
func ReadWAVTags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeWAV(f)
if err != nil {
return nil, err
}
meta := wavMetadataFromProbe(p)
if meta == nil {
return nil, fmt.Errorf("no WAV tags found")
}
return meta, nil
}
// ---------- AIFF / AIFC ----------
type aiffProbe struct {
sampleRate int
bitDepth int
channels int
numFrames int64
id3 []byte
nameChunk string
authChunk string
annoChunk string
copyrightChunk string
}
func streamProbeAIFF(f *os.File) (*aiffProbe, error) {
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return nil, err
}
form := string(header[8:12])
if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") {
return nil, fmt.Errorf("not an AIFF file")
}
p := &aiffProbe{}
hdr := make([]byte, 8)
for {
if _, err := io.ReadFull(f, hdr); err != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], false)
pad := int64(size) & 1
switch id {
case "COMM":
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err != nil {
return p, nil
}
if len(buf) >= 18 {
p.channels = int(binary.BigEndian.Uint16(buf[0:2]))
p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6]))
p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8]))
p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5)
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
case id3ChunkAIFF, "id3 ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
p.id3 = buf
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
case "NAME", "AUTH", "ANNO", "(c) ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00")
switch id {
case "NAME":
p.nameChunk = val
case "AUTH":
p.authChunk = val
case "ANNO":
p.annoChunk = val
case "(c) ":
p.copyrightChunk = val
}
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
default:
f.Seek(int64(size)+pad, io.SeekCurrent)
}
}
return p, nil
}
func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata {
if p == nil {
return nil
}
if len(p.id3) > 0 {
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
return meta
}
}
if p.nameChunk != "" || p.authChunk != "" {
meta := &AudioMetadata{
Title: p.nameChunk,
Artist: p.authChunk,
Comment: p.annoChunk,
Copyright: p.copyrightChunk,
}
return meta
}
return nil
}
// GetAIFFQuality probes PCM parameters and computes duration from frame count.
func GetAIFFQuality(filePath string) (*WAVQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeAIFF(f)
if err != nil {
return nil, err
}
q := &WAVQuality{
SampleRate: p.sampleRate,
BitDepth: p.bitDepth,
Channels: p.channels,
}
if p.sampleRate > 0 && p.numFrames > 0 {
q.Duration = int(p.numFrames / int64(p.sampleRate))
}
return q, nil
}
// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback).
func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeAIFF(f)
if err != nil {
return nil, err
}
meta := aiffMetadataFromProbe(p)
if meta == nil {
return nil, fmt.Errorf("no AIFF tags found")
}
return meta, nil
}
// ---------- ID3v2 reading from a buffered chunk ----------
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
if len(data) < 10 || string(data[0:3]) != "ID3" {
return nil, fmt.Errorf("no ID3v2 header")
}
majorVersion := data[3]
flags := data[5]
unsync := (flags & 0x80) != 0
extendedHeader := (flags & 0x40) != 0
footerPresent := (flags & 0x10) != 0
size := synchsafeDecode(data[6:10])
if size <= 0 || 10+size > len(data) {
size = len(data) - 10
}
tagData := data[10 : 10+size]
if footerPresent && len(tagData) >= 10 {
footerStart := len(tagData) - 10
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
tagData = tagData[:footerStart]
}
}
if extendedHeader {
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
tagData = tagData[skip:]
}
}
metadata := &AudioMetadata{}
if majorVersion == 2 {
parseID3v22Frames(tagData, metadata, unsync)
} else {
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
}
return metadata, nil
}
// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME.
func extractAPICFromID3(tag []byte) ([]byte, string) {
if len(tag) < 10 || string(tag[0:3]) != "ID3" {
return nil, ""
}
ver := tag[3]
size := synchsafeDecode(tag[6:10])
if size <= 0 || 10+size > len(tag) {
size = len(tag) - 10
}
data := tag[10 : 10+size]
pos := 0
for {
if ver == 2 {
if pos+6 > len(data) || data[pos] == 0 {
break
}
id := string(data[pos : pos+3])
fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
if fsz <= 0 || pos+6+fsz > len(data) {
break
}
if id == "PIC" {
return parseAPICFrame(data[pos+6:pos+6+fsz], ver)
}
pos += 6 + fsz
continue
}
if pos+10 > len(data) || data[pos] == 0 {
break
}
id := string(data[pos : pos+4])
var fsz int
if ver == 4 {
fsz = synchsafeDecode(data[pos+4 : pos+8])
} else {
fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
}
if fsz <= 0 || pos+10+fsz > len(data) {
break
}
if id == "APIC" {
return parseAPICFrame(data[pos+10:pos+10+fsz], ver)
}
pos += 10 + fsz
}
return nil, ""
}
// ---------- ID3v2.4 building ----------
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
var frames bytes.Buffer
writeFrame := func(id string, payload []byte) {
frames.WriteString(id)
frames.Write(synchsafeEncode(len(payload)))
frames.Write([]byte{0, 0})
frames.Write(payload)
}
writeText := func(id, val string) {
if strings.TrimSpace(val) == "" {
return
}
payload := append([]byte{0x03}, []byte(val)...)
writeFrame(id, payload)
}
writeText("TIT2", meta.Title)
writeText("TPE1", meta.Artist)
writeText("TALB", meta.Album)
writeText("TPE2", meta.AlbumArtist)
writeText("TCON", meta.Genre)
writeText("TCOM", meta.Composer)
writeText("TPUB", meta.Label)
writeText("TCOP", meta.Copyright)
writeText("TSRC", meta.ISRC)
date := meta.Date
if date == "" {
date = meta.Year
}
writeText("TDRC", date)
if meta.TrackNumber > 0 {
if meta.TotalTracks > 0 {
writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks))
} else {
writeText("TRCK", strconv.Itoa(meta.TrackNumber))
}
}
if meta.DiscNumber > 0 {
if meta.TotalDiscs > 0 {
writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs))
} else {
writeText("TPOS", strconv.Itoa(meta.DiscNumber))
}
}
if strings.TrimSpace(meta.Comment) != "" {
// COMM: encoding + language(3) + short desc(null) + text
payload := []byte{0x03}
payload = append(payload, []byte("eng")...)
payload = append(payload, 0x00) // empty description
payload = append(payload, []byte(meta.Comment)...)
writeFrame("COMM", payload)
}
if strings.TrimSpace(meta.Lyrics) != "" {
payload := []byte{0x03}
payload = append(payload, []byte("eng")...)
payload = append(payload, 0x00)
payload = append(payload, []byte(meta.Lyrics)...)
writeFrame("USLT", payload)
}
// ReplayGain as TXXX (description\0value), UTF-8.
writeTXXX := func(desc, val string) {
if strings.TrimSpace(val) == "" {
return
}
payload := []byte{0x03}
payload = append(payload, []byte(desc)...)
payload = append(payload, 0x00)
payload = append(payload, []byte(val)...)
writeFrame("TXXX", payload)
}
writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain)
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
if len(coverData) > 0 {
if strings.TrimSpace(coverMIME) == "" {
coverMIME = "image/jpeg"
}
// APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data
payload := []byte{0x03}
payload = append(payload, []byte(coverMIME)...)
payload = append(payload, 0x00)
payload = append(payload, 0x03)
payload = append(payload, 0x00)
payload = append(payload, coverData...)
writeFrame("APIC", payload)
}
body := frames.Bytes()
var out bytes.Buffer
out.WriteString("ID3")
out.Write([]byte{0x04, 0x00}) // v2.4.0
out.WriteByte(0x00) // flags
out.Write(synchsafeEncode(len(body)))
out.Write(body)
return out.Bytes()
}
// ---------- tag writing (streaming chunk rewrite) ----------
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
// The audio data and all other chunks are preserved; container size is patched.
func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error {
in, err := os.Open(filePath)
if err != nil {
return err
}
defer in.Close()
header := make([]byte, 12)
if _, err := io.ReadFull(in, header); err != nil {
return err
}
if string(header[0:4]) != expectMagic {
return fmt.Errorf("unexpected container magic %q", string(header[0:4]))
}
tmpPath := filePath + ".tagtmp"
out, err := os.Create(tmpPath)
if err != nil {
return err
}
cleanup := func() {
out.Close()
os.Remove(tmpPath)
}
if _, err := out.Write(header); err != nil {
cleanup()
return err
}
var bodyLen int64 = 4 // the 4-byte form type after the size field
hdr := make([]byte, 8)
for {
n, rerr := io.ReadFull(in, hdr)
if n < 8 {
break
}
if rerr != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], le)
pad := int64(size) & 1
if strings.EqualFold(id, chunkID) {
// Drop the existing tag chunk.
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
cleanup()
return err
}
continue
}
if _, err := out.Write(hdr); err != nil {
cleanup()
return err
}
if _, err := io.CopyN(out, in, int64(size)+pad); err != nil {
cleanup()
return err
}
bodyLen += 8 + int64(size) + pad
}
// Append the new tag chunk.
newSize := len(id3)
chunkHdr := make([]byte, 8)
copy(chunkHdr[0:4], chunkID)
putUint32(chunkHdr[4:8], le, uint32(newSize))
if _, err := out.Write(chunkHdr); err != nil {
cleanup()
return err
}
if _, err := out.Write(id3); err != nil {
cleanup()
return err
}
if newSize&1 == 1 {
if _, err := out.Write([]byte{0}); err != nil {
cleanup()
return err
}
}
bodyLen += 8 + int64(newSize) + int64(newSize&1)
// Patch the container size field (bytes 4..8).
sizeBuf := make([]byte, 4)
putUint32(sizeBuf, le, uint32(bodyLen))
if _, err := out.WriteAt(sizeBuf, 4); err != nil {
cleanup()
return err
}
if err := out.Close(); err != nil {
os.Remove(tmpPath)
return err
}
in.Close()
return os.Rename(tmpPath, filePath)
}
func loadCoverForTag(fields map[string]string) ([]byte, string) {
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath == "" {
return nil, ""
}
data, err := os.ReadFile(coverPath)
if err != nil || len(data) == 0 {
return nil, ""
}
mime := "image/jpeg"
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
mime = "image/png"
}
return data, mime
}
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
atoi := func(k string) int {
n := 0
if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" {
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
}
return n
}
return &AudioMetadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: atoi("track_number"),
TotalTracks: atoi("track_total"),
DiscNumber: atoi("disc_number"),
TotalDiscs: atoi("disc_total"),
ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
ReplayGainTrackGain: fields["replaygain_track_gain"],
ReplayGainTrackPeak: fields["replaygain_track_peak"],
ReplayGainAlbumGain: fields["replaygain_album_gain"],
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
}
}
// mergeWAVEditFields merges edit fields onto existing tags so untouched fields
// (and cover art, when no new cover is provided) are preserved.
func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata {
meta := audioMetadataFromEditFields(fields)
if existing == nil {
return meta
}
// Only overwrite fields that are present as keys in the edit set; otherwise
// keep the existing value. An empty value with the key present clears it.
keep := func(key, newVal, oldVal string) string {
if _, ok := fields[key]; ok {
return newVal
}
return oldVal
}
meta.Title = keep("title", meta.Title, existing.Title)
meta.Artist = keep("artist", meta.Artist, existing.Artist)
meta.Album = keep("album", meta.Album, existing.Album)
meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist)
meta.Genre = keep("genre", meta.Genre, existing.Genre)
meta.Composer = keep("composer", meta.Composer, existing.Composer)
meta.Label = keep("label", meta.Label, existing.Label)
meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright)
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
meta.Comment = keep("comment", meta.Comment, existing.Comment)
meta.Date = keep("date", meta.Date, existing.Date)
if _, ok := fields["track_number"]; !ok {
meta.TrackNumber = existing.TrackNumber
}
if _, ok := fields["track_total"]; !ok {
meta.TotalTracks = existing.TotalTracks
}
if _, ok := fields["disc_number"]; !ok {
meta.DiscNumber = existing.DiscNumber
}
if _, ok := fields["disc_total"]; !ok {
meta.TotalDiscs = existing.TotalDiscs
}
if _, ok := fields["replaygain_track_gain"]; !ok {
meta.ReplayGainTrackGain = existing.ReplayGainTrackGain
}
if _, ok := fields["replaygain_track_peak"]; !ok {
meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak
}
if _, ok := fields["replaygain_album_gain"]; !ok {
meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain
}
if _, ok := fields["replaygain_album_peak"]; !ok {
meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak
}
return meta
}
// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk.
func WriteWAVTags(filePath string, fields map[string]string) error {
existing, _ := ReadWAVTags(filePath)
meta := mergeEditFieldsOntoExisting(existing, fields)
coverData, coverMIME := loadCoverForTag(fields)
if coverData == nil {
// Preserve an existing embedded cover when no new one is supplied.
if f, err := os.Open(filePath); err == nil {
if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 {
coverData, coverMIME = extractAPICFromID3(p.id3)
}
f.Close()
}
}
tag := buildID3v24Tag(meta, coverData, coverMIME)
return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag)
}
// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk.
func WriteAIFFTags(filePath string, fields map[string]string) error {
existing, _ := ReadAIFFTags(filePath)
meta := mergeEditFieldsOntoExisting(existing, fields)
coverData, coverMIME := loadCoverForTag(fields)
if coverData == nil {
if f, err := os.Open(filePath); err == nil {
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
coverData, coverMIME = extractAPICFromID3(p.id3)
}
f.Close()
}
}
tag := buildID3v24Tag(meta, coverData, coverMIME)
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
}
// ---------- library scan integration ----------
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
}
result.Bitrate = 0 // lossless PCM
result.Format = "wav"
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
}
result.Bitrate = 0 // lossless PCM
result.Format = "aiff"
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
}
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
// WAV or AIFF file, or an error when none is present.
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
f, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer f.Close()
var id3 []byte
switch ext {
case ".aiff", ".aif", ".aifc":
if p, perr := streamProbeAIFF(f); perr == nil {
id3 = p.id3
}
default:
if p, perr := streamProbeWAV(f); perr == nil {
id3 = p.id3
}
}
if len(id3) == 0 {
return nil, "", fmt.Errorf("no embedded cover")
}
data, mime := extractAPICFromID3(id3)
if len(data) == 0 {
return nil, "", fmt.Errorf("no embedded cover")
}
return data, mime, nil
}
+9 -28
View File
@@ -968,8 +968,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
@@ -1037,8 +1036,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -1162,7 +1162,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -1198,8 +1198,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
@@ -1304,27 +1303,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
+8 -26
View File
@@ -1216,8 +1216,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
@@ -1285,8 +1284,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -1409,7 +1409,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -1583,27 +1583,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
+12 -49
View File
@@ -4836,8 +4836,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
@@ -4909,8 +4908,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -5049,7 +5049,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -5085,8 +5085,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final total = selectedItems.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
@@ -5196,27 +5195,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
@@ -5309,27 +5290,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
+68 -63
View File
@@ -27,6 +27,7 @@ import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
part 'track_metadata_edit_sheet.dart';
@@ -1739,6 +1740,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return switch (normalized) {
'flac' => 'FLAC',
'alac' => 'ALAC',
'wav' || 'wave' => 'WAV',
'aiff' || 'aif' || 'aifc' => 'AIFF',
'eac3' || 'ec_3' => 'EAC3',
'ac3' || 'ac_3' => 'AC3',
'ac4' || 'ac_4' => 'AC4',
@@ -3320,6 +3323,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataOption(
icon: Icons.share_outlined,
label: l10n.trackMetadataShare,
dividerAbove: true,
onTap: () => _shareFile(screenContext),
),
_MetadataOption(
@@ -3396,14 +3400,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 4),
for (final option in options)
_MetadataOptionTile(
option: option,
colorScheme: colorScheme,
onTap: () =>
_closeOptionsMenuAndRun(sheetContext, option.onTap),
),
const SizedBox(height: 8),
SettingsGroup(
children: [
for (int i = 0; i < options.length; i++) ...[
if (options[i].dividerAbove && i != 0)
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
),
_MetadataOptionTile(
option: options[i],
colorScheme: colorScheme,
onTap: () => _closeOptionsMenuAndRun(
sheetContext,
options[i].onTap,
),
),
],
],
),
const SizedBox(height: 16),
],
),
@@ -3591,7 +3610,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String _buildConvertedQualityLabel(String targetFormat, String bitrate) {
final upper = targetFormat.toUpperCase();
if (upper == 'ALAC' || upper == 'FLAC') {
if (isLosslessConversionTarget(targetFormat)) {
return '$upper Lossless';
}
final normalizedBitrate = bitrate.trim().toLowerCase();
@@ -3664,15 +3683,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _showConvertSheet(BuildContext context) {
final currentFormat = _currentFileFormat;
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
final isLosslessSource = isLosslessConversionSource(currentFormat);
final formats = <String>[];
if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']);
formats.addAll(['ALAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'ALAC') {
formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']);
formats.addAll(['FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'M4A') {
formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']);
formats.addAll(['ALAC', 'FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'WAV') {
formats.addAll(['FLAC', 'ALAC', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'AIFF') {
formats.addAll(['FLAC', 'ALAC', 'WAV', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'AAC') {
formats.addAll(['MP3', 'Opus']);
} else if (currentFormat == 'MP3') {
@@ -3691,8 +3714,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
showModalBottomSheet<void>(
context: context,
@@ -3752,8 +3774,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -4306,9 +4329,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String targetFormat,
required String bitrate,
}) {
final isLossless =
targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
showDialog<void>(
context: context,
builder: (dialogContext) {
@@ -4515,30 +4536,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
@@ -4955,12 +4955,14 @@ class _MetadataOption {
final String label;
final VoidCallback onTap;
final bool destructive;
final bool dividerAbove;
const _MetadataOption({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
this.dividerAbove = false,
});
}
@@ -4977,29 +4979,32 @@ class _MetadataOptionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final boxColor = option.destructive
? colorScheme.errorContainer
: colorScheme.primaryContainer;
final iconColor = option.destructive
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer;
? colorScheme.error
: colorScheme.onSurfaceVariant;
final titleColor = option.destructive ? colorScheme.error : null;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: boxColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: iconColor, size: 20),
),
title: Text(
option.label,
style: TextStyle(fontWeight: FontWeight.w500, color: titleColor),
),
return InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Icon(option.icon, color: iconColor, size: 24),
const SizedBox(width: 16),
Expanded(
child: Text(
option.label,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: titleColor),
),
),
],
),
),
);
}
}
+246 -3
View File
@@ -9,6 +9,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -283,6 +284,28 @@ class FFmpegService {
}.contains(normalized);
}
/// Probes the source audio bit depth (bits_per_raw_sample, falling back to
/// bits_per_sample). Returns null when unknown.
static Future<int?> probeBitDepth(String filePath) async {
try {
final session = await FFprobeKit.getMediaInformation(filePath);
final info = session.getMediaInformation();
if (info == null) return null;
for (final stream in info.getStreams()) {
final props = stream.getAllProperties() ?? const <String, dynamic>{};
if (props['codec_type']?.toString() != 'audio') continue;
final raw = props['bits_per_raw_sample']?.toString();
final bps = props['bits_per_sample']?.toString();
final v = int.tryParse(raw ?? '') ?? int.tryParse(bps ?? '');
if (v != null && v > 0) return v;
return null;
}
} catch (e) {
_log.w('Bit depth probe failed for $filePath: $e');
}
return null;
}
/// Returns `true` when [filePath] starts with the native FLAC magic bytes
/// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4
/// container that carries a `.flac` extension or claims codec=flac.
@@ -2119,8 +2142,9 @@ class FFmpegService {
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC.
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF.
/// ALAC, FLAC, WAV and AIFF targets are lossless (bitrate parameter is ignored).
/// [sourceBitDepth] (when known) preserves 24-bit resolution for WAV/AIFF.
static Future<String?> convertAudioFormat({
required String inputPath,
required String targetFormat,
@@ -2129,9 +2153,19 @@ class FFmpegService {
String? coverPath,
String artistTagMode = artistTagModeJoined,
bool deleteOriginal = true,
int? sourceBitDepth,
}) async {
final format = targetFormat.toLowerCase();
if (!const {'mp3', 'opus', 'aac', 'alac', 'flac'}.contains(format)) {
if (!const {
'mp3',
'opus',
'aac',
'alac',
'flac',
'wav',
'aiff',
'aif',
}.contains(format)) {
_log.e('Unsupported target format: $targetFormat');
return null;
}
@@ -2153,6 +2187,16 @@ class FFmpegService {
deleteOriginal: deleteOriginal,
);
}
if (format == 'wav' || format == 'aiff' || format == 'aif') {
return _convertToPcm(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
container: format == 'wav' ? 'wav' : 'aiff',
sourceBitDepth: sourceBitDepth,
deleteOriginal: deleteOriginal,
);
}
final extension = switch (format) {
'opus' => '.opus',
@@ -2390,6 +2434,205 @@ class FFmpegService {
return outputPath;
}
/// Convert to uncompressed PCM (WAV or AIFF), preserving bit depth when known.
/// Tags and cover are written natively into an embedded ID3 chunk by the Go
/// backend (RIFF "id3 " for WAV, "ID3 " for AIFF) for full-fidelity tagging.
static Future<String?> _convertToPcm({
required String inputPath,
required Map<String, String> metadata,
required String container, // 'wav' or 'aiff'
String? coverPath,
int? sourceBitDepth,
bool deleteOriginal = true,
}) async {
final isAiff = container == 'aiff';
final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav');
var depth = sourceBitDepth;
if (depth == null || depth <= 0) {
depth = await probeBitDepth(inputPath);
}
final use24 = depth != null && depth >= 24;
final codec = isAiff
? (use24 ? 'pcm_s24be' : 'pcm_s16be')
: (use24 ? 'pcm_s24le' : 'pcm_s16le');
final arguments = <String>[
'-v', 'error', '-hide_banner',
'-i', inputPath,
'-map', '0:a',
'-c:a', codec,
'-map_metadata', '-1',
outputPath,
'-y',
];
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit)',
);
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('${container.toUpperCase()} conversion failed: ${result.output}');
return null;
}
// Write tags + cover via the native ID3-chunk writer in the Go backend.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
if (hasMetadata || hasCover) {
final ok = await _embedChunkTagsNative(outputPath, metadata, coverPath);
if (!ok) {
_log.w(
'Native tag embed failed for $container output (file kept untagged)',
);
}
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Writes tags + cover into a WAV/AIFF file via the Go native ID3-chunk
/// writer (PlatformBridge.editFileMetadata). Maps Vorbis-style metadata keys
/// to the lowercase field names the Go editor expects.
static Future<bool> _embedChunkTagsNative(
String path,
Map<String, String> vorbisMetadata,
String? coverPath,
) async {
final fields = _vorbisToNativeChunkFields(vorbisMetadata);
if (coverPath != null && coverPath.trim().isNotEmpty) {
fields['cover_path'] = coverPath;
}
if (fields.isEmpty) return true;
try {
final res = await PlatformBridge.editFileMetadata(path, fields);
return res['error'] == null;
} catch (e) {
_log.w('editFileMetadata for $path failed: $e');
return false;
}
}
/// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field
/// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer.
static Map<String, String> _vorbisToNativeChunkFields(
Map<String, String> metadata,
) {
final out = <String, String>{};
void setIndexPair(String numberKey, String totalKey, String value) {
final v = value.trim();
if (v.isEmpty || v == '0') return;
if (v.contains('/')) {
final parts = v.split('/');
out[numberKey] = parts[0].trim();
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
out[totalKey] = parts[1].trim();
}
} else {
out[numberKey] = v;
}
}
for (final entry in metadata.entries) {
final normalizedKey = entry.key.toUpperCase().replaceAll(
RegExp(r'[^A-Z0-9]'),
'',
);
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (normalizedKey) {
case 'TITLE':
out['title'] = value;
break;
case 'ARTIST':
out['artist'] = value;
break;
case 'ALBUM':
out['album'] = value;
break;
case 'ALBUMARTIST':
out['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
setIndexPair('track_number', 'track_total', value);
break;
case 'TRACKTOTAL':
case 'TOTALTRACKS':
if (value.trim() != '0') out['track_total'] = value.trim();
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
setIndexPair('disc_number', 'disc_total', value);
break;
case 'DISCTOTAL':
case 'TOTALDISCS':
if (value.trim() != '0') out['disc_total'] = value.trim();
break;
case 'DATE':
out['date'] = value;
break;
case 'YEAR':
if ((out['date'] ?? '').isEmpty) out['date'] = value;
break;
case 'ISRC':
out['isrc'] = value;
break;
case 'GENRE':
out['genre'] = value;
break;
case 'COMPOSER':
out['composer'] = value;
break;
case 'ORGANIZATION':
case 'LABEL':
case 'PUBLISHER':
out['label'] = value;
break;
case 'COPYRIGHT':
out['copyright'] = value;
break;
case 'COMMENT':
case 'DESCRIPTION':
out['comment'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
out['lyrics'] = value;
break;
case 'REPLAYGAINTRACKGAIN':
out['replaygain_track_gain'] = value;
break;
case 'REPLAYGAINTRACKPEAK':
out['replaygain_track_peak'] = value;
break;
case 'REPLAYGAINALBUMGAIN':
out['replaygain_album_gain'] = value;
break;
case 'REPLAYGAINALBUMPEAK':
out['replaygain_album_peak'] = value;
break;
}
}
return out;
}
/// Normalize metadata keys to standard Vorbis comment names, filtering out
/// technical fields (bit_depth, sample_rate, duration, etc.).
static Map<String, String> _normalizeToVorbisComments(
+5
View File
@@ -2022,6 +2022,11 @@ class LibraryDatabase {
return 'flac';
case 'opus':
return 'opus';
case 'wav':
return 'wav';
case 'aiff':
case 'aif':
return 'aiff';
default:
return 'mp3';
}
+4
View File
@@ -27,6 +27,10 @@ class ReplayGainService {
'.ape',
'.wv',
'.mpc',
'.wav',
'.aiff',
'.aif',
'.aifc',
};
static bool _isNativeWritableFormat(String path) {
+39 -1
View File
@@ -1,6 +1,8 @@
const List<String> audioConversionTargetFormats = [
'ALAC',
'FLAC',
'WAV',
'AIFF',
'AAC',
'MP3',
'Opus',
@@ -8,7 +10,11 @@ const List<String> audioConversionTargetFormats = [
bool isLosslessConversionTarget(String targetFormat) {
final normalized = targetFormat.trim().toLowerCase();
return normalized == 'alac' || normalized == 'flac';
return normalized == 'alac' ||
normalized == 'flac' ||
normalized == 'wav' ||
normalized == 'aiff' ||
normalized == 'aif';
}
bool isLosslessConversionSource(String sourceFormat) {
@@ -16,6 +22,9 @@ bool isLosslessConversionSource(String sourceFormat) {
case 'FLAC':
case 'ALAC':
case 'M4A':
case 'WAV':
case 'AIFF':
case 'AIF':
return true;
default:
return false;
@@ -66,6 +75,13 @@ String? _convertibleAudioFormatLabel(String? rawFormat) {
return 'FLAC';
case 'alac':
return 'ALAC';
case 'wav':
case 'wave':
return 'WAV';
case 'aiff':
case 'aif':
case 'aifc':
return 'AIFF';
case 'm4a':
case 'mp4':
return 'M4A';
@@ -95,6 +111,28 @@ String normalizedConvertedAudioFormat(String targetFormat) {
return targetFormat.trim().toLowerCase();
}
/// Returns the output file extension (with dot) and MIME type for a conversion
/// target format. Used when creating the converted file via SAF so WAV/AIFF and
/// the other formats get the correct extension + MIME.
({String ext, String mime}) convertTargetExtAndMime(String targetFormat) {
switch (targetFormat.trim().toLowerCase()) {
case 'opus':
return (ext: '.opus', mime: 'audio/opus');
case 'alac':
case 'aac':
return (ext: '.m4a', mime: 'audio/mp4');
case 'flac':
return (ext: '.flac', mime: 'audio/flac');
case 'wav':
return (ext: '.wav', mime: 'audio/wav');
case 'aiff':
case 'aif':
return (ext: '.aiff', mime: 'audio/aiff');
default:
return (ext: '.mp3', mime: 'audio/mpeg');
}
}
int? convertedAudioBitrateKbps({
required String targetFormat,
required String bitrate,
+4
View File
@@ -16,6 +16,10 @@ String audioMimeTypeForPath(String filePath) {
return 'audio/ogg';
case 'wav':
return 'audio/wav';
case 'aiff':
case 'aif':
case 'aifc':
return 'audio/aiff';
case 'aac':
return 'audio/aac';
default:
+2
View File
@@ -19,6 +19,8 @@ const _audioExtensions = <String>[
'.opus',
'.ogg',
'.wav',
'.aiff',
'.aif',
'.aac',
];