diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index ecbdbbe7..0d40173a 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -946,8 +946,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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 { 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 { 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 { 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 { 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( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index bffcb089..5c3c8ea3 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -820,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { 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 { 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( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 18a78c16..0e632411 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4400,6 +4400,11 @@ class _QueueTabState extends ConsumerState { 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 { 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 { 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 { icon: Icons.download_for_offline_outlined, label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', - onPressed: () => _queueSelectedLocalAsFlac(unifiedItems), + onPressed: () => + _queueSelectedLocalAsFlac(unifiedItems), colorScheme: colorScheme, ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 744acf54..81102a3e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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 { 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 { } 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 { 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 { 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 { 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 { 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 = []; @@ -2879,8 +2894,9 @@ class _TrackMetadataScreenState extends ConsumerState { 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 { 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 { 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 = {}; 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, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index f5608c9f..bf43717c 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1106,6 +1106,88 @@ class FFmpegService { return null; } + static Future embedMetadataToM4a({ + required String m4aPath, + String? coverPath, + Map? 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 _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 _convertToM4aTags( - Map metadata, - ) { + static Map _convertToM4aTags(Map metadata) { final m4aMap = {}; 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 = []; 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) { diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index 2e17c397..c20eb411 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -74,3 +74,38 @@ Future ensureLyricsMetadataForConversion({ metadata['LYRICS'] = lyrics; metadata['UNSYNCEDLYRICS'] = lyrics; } + +void mergePlatformMetadataForTagEmbed({ + required Map target, + required Map 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); + } +}