feat: add M4A metadata/cover embed support across all Flutter screens

Add FFmpegService.embedMetadataToM4a() for writing tags and cover art
into M4A files via FFmpeg. Fix two bugs in the same function:
- Remove '-disposition:v:0 attached_pic' which is only valid for
  Matroska/WebM containers and causes FFmpeg to error on MP4/M4A
- Apply same fix to _convertToAlac which had the identical issue

Add M4A handling (isM4A branch) to all four embed call-sites:
track_metadata_screen (lyrics embed, re-enrich, edit metadata sheet,
format conversion), queue_tab, local_album_screen, and
downloaded_album_screen.

Add 'LYRICS'/'UNSYNCEDLYRICS' to _mapMetadataForTagEmbed so existing
lyrics survive a re-enrich cycle on M4A/MP3/Opus files.

Preserve existing lyrics before overwriting tags in the edit metadata
sheet (best-effort readFileMetadata before FFmpeg pass).

Extract mergePlatformMetadataForTagEmbed() into lyrics_metadata_helper
to deduplicate the identical metadata-mapping loops that existed in
queue_tab, local_album_screen, downloaded_album_screen, and
track_metadata_screen.

Wire ensureLyricsMetadataForConversion into the format conversion path
in track_metadata_screen so lyrics are carried through conversions.

Add ISRC and LABEL/ORGANIZATION mappings to _convertToM4aTags.
This commit is contained in:
zarzet
2026-03-22 23:01:32 +07:00
parent 4cf885a52e
commit 12be560cb8
6 changed files with 235 additions and 54 deletions
+11 -16
View File
@@ -946,8 +946,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
String selectedBitrate = isLosslessTarget
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -1009,8 +1010,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
}
});
}
@@ -1055,11 +1057,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
),
@@ -1175,7 +1174,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
@@ -1206,12 +1206,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
+12 -6
View File
@@ -820,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus =
format == 'opus' ||
format == 'ogg' ||
@@ -833,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
@@ -1450,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
+14 -7
View File
@@ -4400,6 +4400,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus =
format == 'opus' ||
format == 'ogg' ||
@@ -4413,6 +4418,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
@@ -5090,12 +5101,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
@@ -5473,7 +5479,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
icon: Icons.download_for_offline_outlined,
label:
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
onPressed: () =>
_queueSelectedLocalAsFlac(unifiedItems),
colorScheme: colorScheme,
),
),
+55 -16
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -1778,6 +1779,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isFlac = lower.endsWith('.flac');
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
bool success = false;
String? error;
@@ -1803,7 +1805,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} else {
error = result['error']?.toString() ?? l10nFailedToEmbedLyrics;
}
} else if (isMp3 || isOpus) {
} else if (isMp3 || isOpus || isM4A) {
final metadata = _buildFallbackMetadata();
try {
final result = await PlatformBridge.readFileMetadata(workingPath);
@@ -1838,6 +1840,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
coverPath: coverPath,
metadata: metadata,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: workingPath,
coverPath: coverPath,
metadata: metadata,
);
} else {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: workingPath,
@@ -2321,6 +2329,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
@@ -2737,6 +2751,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
put('COPYRIGHT', source['copyright']);
put('COMPOSER', source['composer']);
put('COMMENT', source['comment']);
put('LYRICS', source['lyrics']);
put('UNSYNCEDLYRICS', source['lyrics']);
final trackNumber = source['track_number'];
if (trackNumber != null && trackNumber.toString() != '0') {
@@ -2796,8 +2812,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _showConvertSheet(BuildContext context) {
final currentFormat = _currentFileFormat;
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source
final formats = <String>[];
@@ -2879,8 +2894,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
}
});
}
@@ -2929,11 +2945,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
),
@@ -3499,22 +3512,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
SnackBar(content: Text(context.l10n.trackConvertConverting)),
);
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
final metadata = _buildFallbackMetadata();
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final normalizedValue = value.toString().trim();
if (normalizedValue.isEmpty) return;
metadata[key.toUpperCase()] = normalizedValue;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
} else {
_log.w('readFileMetadata returned error, using fallback metadata');
}
} catch (e) {
_log.w('readFileMetadata threw, using fallback metadata: $e');
}
await ensureLyricsMetadataForConversion(
metadata: metadata,
sourcePath: cleanFilePath,
shouldEmbedLyrics: shouldEmbedLyrics,
trackName: trackName,
artistName: artistName,
spotifyId: _spotifyId ?? '',
durationMs: (duration ?? 0) * 1000,
);
String? coverPath;
try {
@@ -4921,6 +4941,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
final vorbisMap = <String, String>{};
if (metadata['title']?.isNotEmpty == true) {
@@ -4964,6 +4985,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (metadata['comment']?.isNotEmpty == true) {
vorbisMap['COMMENT'] = metadata['comment']!;
}
try {
final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget,
);
final existingLyrics = existingMetadata['lyrics']?.toString().trim();
if (existingLyrics != null && existingLyrics.isNotEmpty) {
vorbisMap['LYRICS'] = existingLyrics;
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
}
} catch (_) {
// Lyrics preservation is best-effort.
}
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
String? extractedCoverPath;
@@ -4997,6 +5030,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
coverPath: existingCoverPath,
metadata: vorbisMap,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
+108 -9
View File
@@ -1106,6 +1106,88 @@ class FFmpegService {
return null;
}
static Future<String?> embedMetadataToM4a({
required String m4aPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$m4aPath" ');
final hasCover = coverPath != null && await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
// For M4A/MP4, cover art is mapped as a video stream and stored in the
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
// flag is only valid for Matroska/WebM containers and must NOT be used here.
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
final m4aMetadata = _convertToM4aTags(metadata);
for (final entry in m4aMetadata.entries) {
final sanitizedValue = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" ');
}
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d(
'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(m4aPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(m4aPath);
await tempFile.delete();
_log.d('M4A metadata embedded successfully');
return m4aPath;
} else {
_log.e('Temp M4A output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace M4A file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup temp M4A file: $e');
}
_log.e('M4A Metadata embed failed: ${result.output}');
return null;
}
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
try {
final file = File(imagePath);
@@ -1330,7 +1412,8 @@ class FFmpegService {
cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture
final hasCover = coverPath != null &&
final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
@@ -1338,8 +1421,10 @@ class FFmpegService {
}
cmdBuffer.write('-map 0:a ');
// M4A/MP4 containers store cover art in the 'covr' atom automatically.
// '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here.
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-map 1:v -c:v copy ');
}
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
@@ -1389,7 +1474,8 @@ class FFmpegService {
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
final hasCover = coverPath != null &&
final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
@@ -1508,9 +1594,7 @@ class FFmpegService {
}
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags(
Map<String, String> metadata,
) {
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
final m4aMap = <String, String>{};
for (final entry in metadata.entries) {
@@ -1548,6 +1632,9 @@ class FFmpegService {
case 'GENRE':
m4aMap['genre'] = value;
break;
case 'ISRC':
m4aMap['isrc'] = value;
break;
case 'COMPOSER':
m4aMap['composer'] = value;
break;
@@ -1557,6 +1644,10 @@ class FFmpegService {
case 'COPYRIGHT':
m4aMap['copyright'] = value;
break;
case 'LABEL':
case 'ORGANIZATION':
m4aMap['organization'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value;
@@ -1648,7 +1739,11 @@ class FFmpegService {
final outputPaths = <String>[];
final inputExt = audioPath.toLowerCase().split('.').last;
// For lossless formats, keep as FLAC; for others, keep original format
final outputExt = (inputExt == 'flac' || inputExt == 'wav' || inputExt == 'ape' || inputExt == 'wv')
final outputExt =
(inputExt == 'flac' ||
inputExt == 'wav' ||
inputExt == 'ape' ||
inputExt == 'wv')
? 'flac'
: inputExt;
@@ -1681,7 +1776,9 @@ class FFmpegService {
cmdBuffer.write('-c:a copy ');
}
final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? '');
final artist = track.artist.isNotEmpty
? track.artist
: (albumMetadata['artist'] ?? '');
final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? '';
final date = albumMetadata['date'] ?? '';
@@ -1706,7 +1803,9 @@ class FFmpegService {
cmdBuffer.write('"$outputPath" -y');
final command = cmdBuffer.toString();
_log.d('CUE split track ${track.number}: ${_previewCommandForLog(command)}');
_log.d(
'CUE split track ${track.number}: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
if (!result.success) {
+35
View File
@@ -74,3 +74,38 @@ Future<void> ensureLyricsMetadataForConversion({
metadata['LYRICS'] = lyrics;
metadata['UNSYNCEDLYRICS'] = lyrics;
}
void mergePlatformMetadataForTagEmbed({
required Map<String, String> target,
required Map<String, dynamic> source,
}) {
void put(String key, dynamic value) {
final normalized = value?.toString().trim();
if (normalized == null || normalized.isEmpty) return;
target[key] = normalized;
}
put('TITLE', source['title']);
put('ARTIST', source['artist']);
put('ALBUM', source['album']);
put('ALBUMARTIST', source['album_artist']);
put('DATE', source['date']);
put('ISRC', source['isrc']);
put('GENRE', source['genre']);
put('ORGANIZATION', source['label']);
put('COPYRIGHT', source['copyright']);
put('COMPOSER', source['composer']);
put('COMMENT', source['comment']);
put('LYRICS', source['lyrics']);
put('UNSYNCEDLYRICS', source['lyrics']);
final trackNumber = source['track_number'];
if (trackNumber != null && trackNumber.toString() != '0') {
put('TRACKNUMBER', trackNumber);
}
final discNumber = source['disc_number'];
if (discNumber != null && discNumber.toString() != '0') {
put('DISCNUMBER', discNumber);
}
}