feat(audio): add WAV and AIFF support + settings-style metadata menu

WAV/AIFF: library scan, quality probe, native tag read/write via embedded ID3 chunk (RIFF id3 / AIFF ID3), cover art, ReadFileMetadata, ExtractLyrics, and FLAC<->WAV/AIFF conversion (PCM, bit-depth preserved via ffprobe). Treat WAV/AIFF as lossless across all convert sheets (no bitrate picker, Lossless labels) via isLosslessConversionTarget. Native MIME maps for SAF. Redesign the track metadata three-dot menu to a settings-style grouped card with a single divider above Share.
This commit is contained in:
zarzet
2026-06-12 21:10:37 +07:00
parent 2a2e2924eb
commit b8b670642c
17 changed files with 1481 additions and 172 deletions
+9 -28
View File
@@ -968,8 +968,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
@@ -1037,8 +1036,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -1162,7 +1162,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -1198,8 +1198,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
@@ -1304,27 +1303,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
+8 -26
View File
@@ -1216,8 +1216,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
@@ -1285,8 +1284,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -1409,7 +1409,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -1583,27 +1583,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
+12 -49
View File
@@ -4836,8 +4836,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
@@ -4909,8 +4908,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -5049,7 +5049,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -5085,8 +5085,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final total = selectedItems.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
@@ -5196,27 +5195,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
@@ -5309,27 +5290,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
+68 -63
View File
@@ -27,6 +27,7 @@ import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
part 'track_metadata_edit_sheet.dart';
@@ -1739,6 +1740,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return switch (normalized) {
'flac' => 'FLAC',
'alac' => 'ALAC',
'wav' || 'wave' => 'WAV',
'aiff' || 'aif' || 'aifc' => 'AIFF',
'eac3' || 'ec_3' => 'EAC3',
'ac3' || 'ac_3' => 'AC3',
'ac4' || 'ac_4' => 'AC4',
@@ -3320,6 +3323,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataOption(
icon: Icons.share_outlined,
label: l10n.trackMetadataShare,
dividerAbove: true,
onTap: () => _shareFile(screenContext),
),
_MetadataOption(
@@ -3396,14 +3400,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 4),
for (final option in options)
_MetadataOptionTile(
option: option,
colorScheme: colorScheme,
onTap: () =>
_closeOptionsMenuAndRun(sheetContext, option.onTap),
),
const SizedBox(height: 8),
SettingsGroup(
children: [
for (int i = 0; i < options.length; i++) ...[
if (options[i].dividerAbove && i != 0)
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
),
_MetadataOptionTile(
option: options[i],
colorScheme: colorScheme,
onTap: () => _closeOptionsMenuAndRun(
sheetContext,
options[i].onTap,
),
),
],
],
),
const SizedBox(height: 16),
],
),
@@ -3591,7 +3610,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String _buildConvertedQualityLabel(String targetFormat, String bitrate) {
final upper = targetFormat.toUpperCase();
if (upper == 'ALAC' || upper == 'FLAC') {
if (isLosslessConversionTarget(targetFormat)) {
return '$upper Lossless';
}
final normalizedBitrate = bitrate.trim().toLowerCase();
@@ -3664,15 +3683,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _showConvertSheet(BuildContext context) {
final currentFormat = _currentFileFormat;
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
final isLosslessSource = isLosslessConversionSource(currentFormat);
final formats = <String>[];
if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']);
formats.addAll(['ALAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'ALAC') {
formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']);
formats.addAll(['FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'M4A') {
formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']);
formats.addAll(['ALAC', 'FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'WAV') {
formats.addAll(['FLAC', 'ALAC', 'AIFF', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'AIFF') {
formats.addAll(['FLAC', 'ALAC', 'WAV', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'AAC') {
formats.addAll(['MP3', 'Opus']);
} else if (currentFormat == 'MP3') {
@@ -3691,8 +3714,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
showModalBottomSheet<void>(
context: context,
@@ -3752,8 +3774,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
@@ -4306,9 +4329,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String targetFormat,
required String bitrate,
}) {
final isLossless =
targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
showDialog<void>(
context: context,
builder: (dialogContext) {
@@ -4515,30 +4536,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
@@ -4955,12 +4955,14 @@ class _MetadataOption {
final String label;
final VoidCallback onTap;
final bool destructive;
final bool dividerAbove;
const _MetadataOption({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
this.dividerAbove = false,
});
}
@@ -4977,29 +4979,32 @@ class _MetadataOptionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final boxColor = option.destructive
? colorScheme.errorContainer
: colorScheme.primaryContainer;
final iconColor = option.destructive
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer;
? colorScheme.error
: colorScheme.onSurfaceVariant;
final titleColor = option.destructive ? colorScheme.error : null;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: boxColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: iconColor, size: 20),
),
title: Text(
option.label,
style: TextStyle(fontWeight: FontWeight.w500, color: titleColor),
),
return InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Icon(option.icon, color: iconColor, size: 24),
const SizedBox(width: 16),
Expanded(
child: Text(
option.label,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: titleColor),
),
),
],
),
),
);
}
}
+246 -3
View File
@@ -9,6 +9,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -283,6 +284,28 @@ class FFmpegService {
}.contains(normalized);
}
/// Probes the source audio bit depth (bits_per_raw_sample, falling back to
/// bits_per_sample). Returns null when unknown.
static Future<int?> probeBitDepth(String filePath) async {
try {
final session = await FFprobeKit.getMediaInformation(filePath);
final info = session.getMediaInformation();
if (info == null) return null;
for (final stream in info.getStreams()) {
final props = stream.getAllProperties() ?? const <String, dynamic>{};
if (props['codec_type']?.toString() != 'audio') continue;
final raw = props['bits_per_raw_sample']?.toString();
final bps = props['bits_per_sample']?.toString();
final v = int.tryParse(raw ?? '') ?? int.tryParse(bps ?? '');
if (v != null && v > 0) return v;
return null;
}
} catch (e) {
_log.w('Bit depth probe failed for $filePath: $e');
}
return null;
}
/// Returns `true` when [filePath] starts with the native FLAC magic bytes
/// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4
/// container that carries a `.flac` extension or claims codec=flac.
@@ -2119,8 +2142,9 @@ class FFmpegService {
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC.
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF.
/// ALAC, FLAC, WAV and AIFF targets are lossless (bitrate parameter is ignored).
/// [sourceBitDepth] (when known) preserves 24-bit resolution for WAV/AIFF.
static Future<String?> convertAudioFormat({
required String inputPath,
required String targetFormat,
@@ -2129,9 +2153,19 @@ class FFmpegService {
String? coverPath,
String artistTagMode = artistTagModeJoined,
bool deleteOriginal = true,
int? sourceBitDepth,
}) async {
final format = targetFormat.toLowerCase();
if (!const {'mp3', 'opus', 'aac', 'alac', 'flac'}.contains(format)) {
if (!const {
'mp3',
'opus',
'aac',
'alac',
'flac',
'wav',
'aiff',
'aif',
}.contains(format)) {
_log.e('Unsupported target format: $targetFormat');
return null;
}
@@ -2153,6 +2187,16 @@ class FFmpegService {
deleteOriginal: deleteOriginal,
);
}
if (format == 'wav' || format == 'aiff' || format == 'aif') {
return _convertToPcm(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
container: format == 'wav' ? 'wav' : 'aiff',
sourceBitDepth: sourceBitDepth,
deleteOriginal: deleteOriginal,
);
}
final extension = switch (format) {
'opus' => '.opus',
@@ -2390,6 +2434,205 @@ class FFmpegService {
return outputPath;
}
/// Convert to uncompressed PCM (WAV or AIFF), preserving bit depth when known.
/// Tags and cover are written natively into an embedded ID3 chunk by the Go
/// backend (RIFF "id3 " for WAV, "ID3 " for AIFF) for full-fidelity tagging.
static Future<String?> _convertToPcm({
required String inputPath,
required Map<String, String> metadata,
required String container, // 'wav' or 'aiff'
String? coverPath,
int? sourceBitDepth,
bool deleteOriginal = true,
}) async {
final isAiff = container == 'aiff';
final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav');
var depth = sourceBitDepth;
if (depth == null || depth <= 0) {
depth = await probeBitDepth(inputPath);
}
final use24 = depth != null && depth >= 24;
final codec = isAiff
? (use24 ? 'pcm_s24be' : 'pcm_s16be')
: (use24 ? 'pcm_s24le' : 'pcm_s16le');
final arguments = <String>[
'-v', 'error', '-hide_banner',
'-i', inputPath,
'-map', '0:a',
'-c:a', codec,
'-map_metadata', '-1',
outputPath,
'-y',
];
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit)',
);
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('${container.toUpperCase()} conversion failed: ${result.output}');
return null;
}
// Write tags + cover via the native ID3-chunk writer in the Go backend.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
if (hasMetadata || hasCover) {
final ok = await _embedChunkTagsNative(outputPath, metadata, coverPath);
if (!ok) {
_log.w(
'Native tag embed failed for $container output (file kept untagged)',
);
}
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Writes tags + cover into a WAV/AIFF file via the Go native ID3-chunk
/// writer (PlatformBridge.editFileMetadata). Maps Vorbis-style metadata keys
/// to the lowercase field names the Go editor expects.
static Future<bool> _embedChunkTagsNative(
String path,
Map<String, String> vorbisMetadata,
String? coverPath,
) async {
final fields = _vorbisToNativeChunkFields(vorbisMetadata);
if (coverPath != null && coverPath.trim().isNotEmpty) {
fields['cover_path'] = coverPath;
}
if (fields.isEmpty) return true;
try {
final res = await PlatformBridge.editFileMetadata(path, fields);
return res['error'] == null;
} catch (e) {
_log.w('editFileMetadata for $path failed: $e');
return false;
}
}
/// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field
/// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer.
static Map<String, String> _vorbisToNativeChunkFields(
Map<String, String> metadata,
) {
final out = <String, String>{};
void setIndexPair(String numberKey, String totalKey, String value) {
final v = value.trim();
if (v.isEmpty || v == '0') return;
if (v.contains('/')) {
final parts = v.split('/');
out[numberKey] = parts[0].trim();
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
out[totalKey] = parts[1].trim();
}
} else {
out[numberKey] = v;
}
}
for (final entry in metadata.entries) {
final normalizedKey = entry.key.toUpperCase().replaceAll(
RegExp(r'[^A-Z0-9]'),
'',
);
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (normalizedKey) {
case 'TITLE':
out['title'] = value;
break;
case 'ARTIST':
out['artist'] = value;
break;
case 'ALBUM':
out['album'] = value;
break;
case 'ALBUMARTIST':
out['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
setIndexPair('track_number', 'track_total', value);
break;
case 'TRACKTOTAL':
case 'TOTALTRACKS':
if (value.trim() != '0') out['track_total'] = value.trim();
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
setIndexPair('disc_number', 'disc_total', value);
break;
case 'DISCTOTAL':
case 'TOTALDISCS':
if (value.trim() != '0') out['disc_total'] = value.trim();
break;
case 'DATE':
out['date'] = value;
break;
case 'YEAR':
if ((out['date'] ?? '').isEmpty) out['date'] = value;
break;
case 'ISRC':
out['isrc'] = value;
break;
case 'GENRE':
out['genre'] = value;
break;
case 'COMPOSER':
out['composer'] = value;
break;
case 'ORGANIZATION':
case 'LABEL':
case 'PUBLISHER':
out['label'] = value;
break;
case 'COPYRIGHT':
out['copyright'] = value;
break;
case 'COMMENT':
case 'DESCRIPTION':
out['comment'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
out['lyrics'] = value;
break;
case 'REPLAYGAINTRACKGAIN':
out['replaygain_track_gain'] = value;
break;
case 'REPLAYGAINTRACKPEAK':
out['replaygain_track_peak'] = value;
break;
case 'REPLAYGAINALBUMGAIN':
out['replaygain_album_gain'] = value;
break;
case 'REPLAYGAINALBUMPEAK':
out['replaygain_album_peak'] = value;
break;
}
}
return out;
}
/// Normalize metadata keys to standard Vorbis comment names, filtering out
/// technical fields (bit_depth, sample_rate, duration, etc.).
static Map<String, String> _normalizeToVorbisComments(
+5
View File
@@ -2022,6 +2022,11 @@ class LibraryDatabase {
return 'flac';
case 'opus':
return 'opus';
case 'wav':
return 'wav';
case 'aiff':
case 'aif':
return 'aiff';
default:
return 'mp3';
}
+4
View File
@@ -27,6 +27,10 @@ class ReplayGainService {
'.ape',
'.wv',
'.mpc',
'.wav',
'.aiff',
'.aif',
'.aifc',
};
static bool _isNativeWritableFormat(String path) {
+39 -1
View File
@@ -1,6 +1,8 @@
const List<String> audioConversionTargetFormats = [
'ALAC',
'FLAC',
'WAV',
'AIFF',
'AAC',
'MP3',
'Opus',
@@ -8,7 +10,11 @@ const List<String> audioConversionTargetFormats = [
bool isLosslessConversionTarget(String targetFormat) {
final normalized = targetFormat.trim().toLowerCase();
return normalized == 'alac' || normalized == 'flac';
return normalized == 'alac' ||
normalized == 'flac' ||
normalized == 'wav' ||
normalized == 'aiff' ||
normalized == 'aif';
}
bool isLosslessConversionSource(String sourceFormat) {
@@ -16,6 +22,9 @@ bool isLosslessConversionSource(String sourceFormat) {
case 'FLAC':
case 'ALAC':
case 'M4A':
case 'WAV':
case 'AIFF':
case 'AIF':
return true;
default:
return false;
@@ -66,6 +75,13 @@ String? _convertibleAudioFormatLabel(String? rawFormat) {
return 'FLAC';
case 'alac':
return 'ALAC';
case 'wav':
case 'wave':
return 'WAV';
case 'aiff':
case 'aif':
case 'aifc':
return 'AIFF';
case 'm4a':
case 'mp4':
return 'M4A';
@@ -95,6 +111,28 @@ String normalizedConvertedAudioFormat(String targetFormat) {
return targetFormat.trim().toLowerCase();
}
/// Returns the output file extension (with dot) and MIME type for a conversion
/// target format. Used when creating the converted file via SAF so WAV/AIFF and
/// the other formats get the correct extension + MIME.
({String ext, String mime}) convertTargetExtAndMime(String targetFormat) {
switch (targetFormat.trim().toLowerCase()) {
case 'opus':
return (ext: '.opus', mime: 'audio/opus');
case 'alac':
case 'aac':
return (ext: '.m4a', mime: 'audio/mp4');
case 'flac':
return (ext: '.flac', mime: 'audio/flac');
case 'wav':
return (ext: '.wav', mime: 'audio/wav');
case 'aiff':
case 'aif':
return (ext: '.aiff', mime: 'audio/aiff');
default:
return (ext: '.mp3', mime: 'audio/mpeg');
}
}
int? convertedAudioBitrateKbps({
required String targetFormat,
required String bitrate,
+4
View File
@@ -16,6 +16,10 @@ String audioMimeTypeForPath(String filePath) {
return 'audio/ogg';
case 'wav':
return 'audio/wav';
case 'aiff':
case 'aif':
case 'aifc':
return 'audio/aiff';
case 'aac':
return 'audio/aac';
default:
+2
View File
@@ -19,6 +19,8 @@ const _audioExtensions = <String>[
'.opus',
'.ogg',
'.wav',
'.aiff',
'.aif',
'.aac',
];