feat: add AAC lossy target and toggle for Apple Music eLRC word sync

The HIGH-quality lossy format picker can now produce an AAC/M4A 320 kbps output alongside MP3 and Opus. FFmpegService.convertM4aToLossy/convertAudioFormat, the Dart queue pipeline, the Kotlin finalizer, and the library database format helper all route .m4a through a unified aac codec path and tag the resulting file with the M4A metadata writer. The Lossy Format setting gains a new option, and the track metadata convert dialog lists AAC next to the other targets.

Apple Music lyrics gain a 'eLRC word sync' switch (default off). When disabled the pax-to-LRC formatter strips inline word timestamps, producing line-synced LRC that is safer for players that choke on eLRC; enabling it restores the previous word-by-word behaviour. The change propagates through SetLyricsFetchOptions and invalidates the global lyrics cache on toggle.

Broad l10n migration: roughly 400 previously hardcoded English strings across queue, settings, track metadata, repo, audio analysis, setup and extension screens now live in the ARB catalog, with matching plural/placeholder forms. No behaviour change beyond localisation. Existing and new unit tests (lyrics eLRC toggle and Dart settings round-trip) pass.
This commit is contained in:
zarzet
2026-05-12 02:23:04 +07:00
parent 7845ac8be5
commit 2a2d817314
47 changed files with 6428 additions and 277 deletions
+30 -8
View File
@@ -295,7 +295,7 @@ class FFmpegService {
header[0] == 0x66 && // 'f'
header[1] == 0x4C && // 'L'
header[2] == 0x61 && // 'a'
header[3] == 0x43; // 'C'
header[3] == 0x43; // 'C'
} finally {
await raf.close();
}
@@ -330,7 +330,8 @@ class FFmpegService {
String? bitrate,
bool deleteOriginal = true,
}) async {
String bitrateValue = format == 'opus' ? '128k' : '320k';
final normalizedFormat = format.toLowerCase();
String bitrateValue = normalizedFormat == 'opus' ? '128k' : '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
@@ -338,13 +339,20 @@ class FFmpegService {
}
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final extension = switch (normalizedFormat) {
'opus' => '.opus',
'aac' || 'm4a' => '.m4a',
_ => '.mp3',
};
final outputPath = _buildOutputPath(inputPath, extension);
String command;
if (format == 'opus') {
if (normalizedFormat == 'opus') {
command =
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else if (normalizedFormat == 'aac' || normalizedFormat == 'm4a') {
command =
'-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrateValue -map 0:a -f mp4 "$outputPath" -y';
} else {
command =
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
@@ -361,7 +369,7 @@ class FFmpegService {
return outputPath;
}
_log.e('M4A to $format conversion failed: ${result.output}');
_log.e('M4A to $normalizedFormat conversion failed: ${result.output}');
return null;
}
@@ -1839,7 +1847,7 @@ class FFmpegService {
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC.
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
static Future<String?> convertAudioFormat({
required String inputPath,
@@ -1851,7 +1859,7 @@ class FFmpegService {
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) {
if (!const {'mp3', 'opus', 'aac', 'alac', 'flac'}.contains(format)) {
_log.e('Unsupported target format: $targetFormat');
return null;
}
@@ -1874,13 +1882,20 @@ class FFmpegService {
);
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final extension = switch (format) {
'opus' => '.opus',
'aac' => '.m4a',
_ => '.mp3',
};
final outputPath = _buildOutputPath(inputPath, extension);
String command;
if (format == 'opus') {
command =
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else if (format == 'aac') {
command =
'-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -f mp4 "$outputPath" -y';
} else {
command =
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
@@ -1907,6 +1922,13 @@ class FFmpegService {
coverPath: coverPath,
metadata: metadata,
);
} else if (format == 'aac') {
embedResult = await embedMetadataToM4a(
m4aPath: outputPath,
coverPath: coverPath,
metadata: metadata,
preserveMetadata: true,
);
} else {
embedResult = await embedMetadataToOpus(
opusPath: outputPath,
+6 -1
View File
@@ -1753,7 +1753,9 @@ class LibraryDatabase {
bitrate: bitrate,
);
if (normalizedFormat == 'mp3' || normalizedFormat == 'opus') {
if (normalizedFormat == 'mp3' ||
normalizedFormat == 'opus' ||
normalizedFormat == 'aac') {
updated['bitDepth'] = null;
}
@@ -2014,6 +2016,8 @@ class LibraryDatabase {
switch (targetFormat.trim().toLowerCase()) {
case 'alac':
return 'm4a';
case 'aac':
return 'aac';
case 'flac':
return 'flac';
case 'opus':
@@ -2030,6 +2034,7 @@ class LibraryDatabase {
switch (targetFormat.trim().toLowerCase()) {
case 'mp3':
case 'opus':
case 'aac':
final match = RegExp(r'(\d+)').firstMatch(bitrate);
return match != null ? int.tryParse(match.group(1)!) : null;
default: