From ed020c93038de481eeada14e6f499eb5819e81ec Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Apr 2026 05:01:02 +0700 Subject: [PATCH] feat: native M4A ReplayGain tag writing and SAF picker error handling --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 20 +- go_backend/exports.go | 37 +++ go_backend/metadata.go | 276 ++++++++++++++++++ lib/providers/download_queue_provider.dart | 19 +- lib/screens/setup_screen.dart | 19 +- lib/services/ffmpeg_service.dart | 13 - 6 files changed, 367 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 5c75396..5b3e1ae 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2200,7 +2200,6 @@ class MainActivity: FlutterFragmentActivity() { result.error("saf_pending", "SAF picker already active", null) return@launch } - pendingSafTreeResult = result val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) intent.addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION or @@ -2208,7 +2207,24 @@ class MainActivity: FlutterFragmentActivity() { Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION ) - safTreeLauncher.launch(intent) + val resolver = intent.resolveActivity(packageManager) + if (resolver == null) { + result.error("saf_unavailable", "No folder picker available on this device", null) + return@launch + } + pendingSafTreeResult = result + try { + android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver") + safTreeLauncher.launch(intent) + } catch (e: Exception) { + pendingSafTreeResult = null + android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e) + result.error( + "saf_launch_failed", + e.message ?: "Failed to launch folder picker", + null + ) + } } "safExists" -> { val uriStr = call.argument("uri") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index eac4b4f..443905e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1485,6 +1485,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { lower := strings.ToLower(filePath) 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") coverPath := strings.TrimSpace(fields["cover_path"]) if isFlac { @@ -1597,6 +1598,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } + if isM4AFile && hasOnlyM4AReplayGainFields(fields) { + if err := EditM4AReplayGain(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write M4A metadata: %w", err) + } + + resp := map[string]any{ + "success": true, + "method": "native_m4a_replaygain", + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + resp := map[string]any{ "success": true, "method": "ffmpeg", @@ -1606,6 +1620,29 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } +func hasOnlyM4AReplayGainFields(fields map[string]string) bool { + allowed := map[string]struct{}{ + "replaygain_track_gain": {}, + "replaygain_track_peak": {}, + "replaygain_album_gain": {}, + "replaygain_album_peak": {}, + } + + hasReplayGain := false + for key, value := range fields { + if strings.TrimSpace(value) == "" { + continue + } + if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok { + hasReplayGain = true + continue + } + return false + } + + return hasReplayGain +} + func SetDownloadDirectory(path string) error { return setDownloadDir(path) } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 67453f7..1fe5271 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -9,6 +9,7 @@ import ( _ "image/jpeg" _ "image/png" "io" + "math" "os" "path/filepath" "regexp" @@ -1244,6 +1245,281 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string return nameValue, dataValue, nil } +type m4aMetadataPath struct { + moov atomHeader + udta *atomHeader + meta atomHeader + ilst atomHeader +} + +func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) { + moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil || !found { + return m4aMetadataPath{}, fmt.Errorf("moov not found") + } + + moovBodyStart := moov.offset + moov.headerSize + moovBodySize := moov.size - moov.headerSize + + if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok { + udtaBodyStart := udta.offset + udta.headerSize + udtaBodySize := udta.size - udta.headerSize + if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 { + metaBodyStart := meta.offset + meta.headerSize + 4 + metaBodySize := meta.size - meta.headerSize - 4 + if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 { + udtaCopy := udta + return m4aMetadataPath{ + moov: moov, + udta: &udtaCopy, + meta: meta, + ilst: ilst, + }, nil + } + } + } + + if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok { + metaBodyStart := meta.offset + meta.headerSize + 4 + metaBodySize := meta.size - meta.headerSize - 4 + if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 { + return m4aMetadataPath{ + moov: moov, + meta: meta, + ilst: ilst, + }, nil + } + } + + return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)") +} + +func buildM4AAtom(typ string, payload []byte) []byte { + size := int64(8 + len(payload)) + buf := make([]byte, 8+len(payload)) + binary.BigEndian.PutUint32(buf[0:4], uint32(size)) + copy(buf[4:8], []byte(typ)) + copy(buf[8:], payload) + return buf +} + +func buildM4AFreeformAtom(name, value string) []byte { + meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...) + namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...) + dataPayload := make([]byte, 8+len(value)) + binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text + copy(dataPayload[8:], []byte(value)) + + payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...) + payload = append(payload, buildM4AAtom("name", namePayload)...) + payload = append(payload, buildM4AAtom("data", dataPayload)...) + return buildM4AAtom("----", payload) +} + +func buildITunNORMTag(trackGain, trackPeak string) string { + gainDb, ok := parseReplayGainDb(trackGain) + if !ok { + return "" + } + peakLinear, ok := parseReplayGainPeak(trackPeak) + if !ok { + return "" + } + + clamp := func(v int64) int64 { + if v < 0 { + return 0 + } + if v > 65534 { + return 65534 + } + return v + } + + g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0))) + g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0))) + peak := clamp(int64(math.Round(peakLinear * 32768.0))) + values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0} + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value))) + } + return strings.Join(parts, " ") +} + +func parseReplayGainDb(value string) (float64, bool) { + match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value)) + if len(match) < 2 { + return 0, false + } + parsed, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0, false + } + return parsed, true +} + +func parseReplayGainPeak(value string) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || parsed <= 0 { + return 0, false + } + return parsed, true +} + +func collectM4AReplayGainFields(fields map[string]string) map[string]string { + result := map[string]string{} + if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" { + result["replaygain_track_gain"] = value + } + if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" { + result["replaygain_track_peak"] = value + } + if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" { + result["replaygain_album_gain"] = value + } + if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" { + result["replaygain_album_peak"] = value + } + + if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" { + result["iTunNORM"] = norm + } + + return result +} + +func writeAtomSize(buf []byte, header atomHeader, newSize int64) error { + if newSize <= 0 { + return fmt.Errorf("invalid size for %s", header.typ) + } + if header.headerSize == 16 { + if int(header.offset)+16 > len(buf) { + return io.ErrUnexpectedEOF + } + binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1) + binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize)) + return nil + } + if newSize > math.MaxUint32 { + return fmt.Errorf("atom %s too large for 32-bit header", header.typ) + } + if int(header.offset)+8 > len(buf) { + return io.ErrUnexpectedEOF + } + binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize)) + return nil +} + +func EditM4AReplayGain(filePath string, fields map[string]string) error { + replayGainFields := collectM4AReplayGainFields(fields) + if len(replayGainFields) == 0 { + return nil + } + + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + + path, err := findM4AMetadataPath(f, info.Size()) + if err != nil { + return err + } + + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + bodyStart := path.ilst.offset + path.ilst.headerSize + bodyEnd := path.ilst.offset + path.ilst.size + newBody := make([]byte, 0, int(path.ilst.size)) + targets := map[string]struct{}{ + "REPLAYGAIN_TRACK_GAIN": {}, + "REPLAYGAIN_TRACK_PEAK": {}, + "REPLAYGAIN_ALBUM_GAIN": {}, + "REPLAYGAIN_ALBUM_PEAK": {}, + "ITUNNORM": {}, + } + + for pos := bodyStart; pos+8 <= bodyEnd; { + header, readErr := readAtomHeaderAt(f, pos, info.Size()) + if readErr != nil { + return readErr + } + if header.size == 0 { + header.size = bodyEnd - pos + } + if header.size < header.headerSize { + return fmt.Errorf("invalid atom size for %s", header.typ) + } + + keep := true + if header.typ == "----" { + name, _, freeformErr := readM4AFreeformValue(f, header, info.Size()) + if freeformErr == nil { + if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok { + keep = false + } + } + } + if keep { + newBody = append(newBody, data[pos:pos+header.size]...) + } + + pos += header.size + } + + order := []string{ + "replaygain_track_gain", + "replaygain_track_peak", + "replaygain_album_gain", + "replaygain_album_peak", + "iTunNORM", + } + for _, key := range order { + value := strings.TrimSpace(replayGainFields[key]) + if value == "" { + continue + } + name := key + if key != "iTunNORM" { + name = strings.ToLower(key) + } + newBody = append(newBody, buildM4AFreeformAtom(name, value)...) + } + + newIlst := buildM4AAtom("ilst", newBody) + updated := append([]byte{}, data[:path.ilst.offset]...) + updated = append(updated, newIlst...) + updated = append(updated, data[path.ilst.offset+path.ilst.size:]...) + + delta := int64(len(newIlst)) - path.ilst.size + if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil { + return err + } + if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil { + return err + } + if path.udta != nil { + if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil { + return err + } + } + if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil { + return err + } + + return os.WriteFile(filePath, updated, 0o644) +} + func extractLyricsFromSidecarLRC(filePath string) (string, error) { ext := filepath.Ext(filePath) base := strings.TrimSuffix(filePath, ext) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 84bb08d..d2506bf 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3910,11 +3910,14 @@ class DownloadQueueNotifier extends Notifier { } } - // ── ReplayGain (MP3/Opus: scan before FFmpeg, add to metadata) ─ + ReplayGainResult? scannedReplayGain; + + // ── ReplayGain (MP3/Opus/M4A: scan before FFmpeg, add to metadata) ─ if (settings.embedReplayGain && !isFlac) { try { final rgResult = await FFmpegService.scanReplayGain(filePath); if (rgResult != null) { + scannedReplayGain = rgResult; metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; _log.d( @@ -3967,6 +3970,20 @@ class DownloadQueueNotifier extends Notifier { _log.w('FFmpeg $format metadata embed failed'); } + if (isM4a && settings.embedReplayGain && scannedReplayGain != null) { + try { + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_track_gain': scannedReplayGain.trackGain, + 'replaygain_track_peak': scannedReplayGain.trackPeak, + }); + _log.d( + 'ReplayGain compatibility tags written for $format: gain=${scannedReplayGain.trackGain}, peak=${scannedReplayGain.trackPeak}', + ); + } catch (e) { + _log.w('Failed to write native ReplayGain tags for $format: $e'); + } + } + // ── FLAC post-processing ──────────────────────────────────────── if (isFlac) { if (settings.artistTagMode == artistTagModeSplitVorbis) { diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 01a43d5..bc6b22d 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -10,6 +10,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('SetupScreen'); class SetupScreen extends ConsumerStatefulWidget { const SetupScreen({super.key}); @@ -233,7 +236,21 @@ class _SetupScreenState extends ConsumerState { if (Platform.isIOS) { await _showIOSDirectoryOptions(); } else if (Platform.isAndroid) { - final result = await PlatformBridge.pickSafTree(); + Map? result; + try { + result = await PlatformBridge.pickSafTree(); + } catch (e) { + _log.w('Failed to open Android SAF picker: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile(e.toString()), + ), + ), + ); + } + } if (result != null) { final treeUri = result['tree_uri'] as String? ?? ''; final displayName = result['display_name'] as String? ?? ''; diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index bcf5bea..69a66b5 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -2169,19 +2169,6 @@ class FFmpegService { case 'UNSYNCEDLYRICS': m4aMap['lyrics'] = value; break; - // ReplayGain as iTunes freeform atoms (com.apple.iTunes:replaygain_*) - case 'REPLAYGAINTRACKGAIN': - m4aMap['REPLAYGAIN_TRACK_GAIN'] = value; - break; - case 'REPLAYGAINTRACKPEAK': - m4aMap['REPLAYGAIN_TRACK_PEAK'] = value; - break; - case 'REPLAYGAINALBUMGAIN': - m4aMap['REPLAYGAIN_ALBUM_GAIN'] = value; - break; - case 'REPLAYGAINALBUMPEAK': - m4aMap['REPLAYGAIN_ALBUM_PEAK'] = value; - break; } }