mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-19 06:38:08 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user