mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-14 23:20:25 +02:00
feat: detect FLAC/ALAC/EAC3/AC3/AC4 codecs inside MP4 containers
GetM4AQuality now recognizes fLaC, alac, ec-3, ac-3, and ac-4 sample entries and parses the MP4 FLACSpecificBox so library entries carry the real codec rather than the container extension. The AudioQuality struct exposes Codec and Bitrate fields (with an estimator for compressed streams), and ReadFileMetadata publishes format + audio_codec so Flutter and Kotlin can make format decisions based on the actual stream. Downstream: library_scan labels M4A-family items as flac/alac/eac3/ac3/ac4/m4a, zeroes the bitrate for lossless formats, and the filter UI + quality badges use the codec-derived format instead of only the file extension. Scans and SAF importers also accept .mp4 and .aac file extensions. New unit tests cover codec name mapping and MP4 FLACSpecificBox decoding.
This commit is contained in:
@@ -73,15 +73,21 @@ String? _nonPlaceholderQuality(String? quality) {
|
||||
String? _resolveDisplayQuality({
|
||||
required String? filePath,
|
||||
String? fileName,
|
||||
String? detectedFormat,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrateKbps,
|
||||
String? storedQuality,
|
||||
}) {
|
||||
final format = _audioFormatForPath(filePath, fileName: fileName);
|
||||
final format =
|
||||
_displayFormatForCodec(detectedFormat) ??
|
||||
_audioFormatForPath(filePath, fileName: fileName);
|
||||
if (format == 'OPUS' ||
|
||||
format == 'MP3' ||
|
||||
format == 'AAC' ||
|
||||
format == 'EAC3' ||
|
||||
format == 'AC3' ||
|
||||
format == 'AC4' ||
|
||||
(format == 'M4A' && (bitDepth == null || bitDepth <= 0))) {
|
||||
return buildDisplayAudioQuality(bitrateKbps: bitrateKbps, format: format) ??
|
||||
_nonPlaceholderQuality(storedQuality) ??
|
||||
@@ -94,6 +100,23 @@ String? _resolveDisplayQuality({
|
||||
);
|
||||
}
|
||||
|
||||
String? _displayFormatForCodec(String? value) {
|
||||
final normalized = normalizeOptionalString(
|
||||
value,
|
||||
)?.toLowerCase().replaceAll('-', '_');
|
||||
return switch (normalized) {
|
||||
'flac' => 'FLAC',
|
||||
'alac' => 'ALAC',
|
||||
'aac' || 'mp4a' => 'AAC',
|
||||
'eac3' || 'ec_3' => 'EAC3',
|
||||
'ac3' || 'ac_3' => 'AC3',
|
||||
'ac4' || 'ac_4' => 'AC4',
|
||||
'mp3' => 'MP3',
|
||||
'opus' => 'OPUS',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// log10 helper using dart:math's natural log.
|
||||
double _log10(num x) => log(x) / ln10;
|
||||
final _yearRegex = RegExp(r'^(\d{4})');
|
||||
@@ -741,6 +764,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']);
|
||||
final quality = _resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
detectedFormat:
|
||||
result['audio_codec']?.toString() ?? result['format']?.toString(),
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrateKbps: bitrateKbps,
|
||||
|
||||
@@ -1486,6 +1486,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String _itemFormatLower(UnifiedLibraryItem item) {
|
||||
final localFormat = normalizeOptionalString(item.localItem?.format);
|
||||
if (localFormat != null) {
|
||||
return localFormat.toLowerCase().replaceAll('-', '_');
|
||||
}
|
||||
return _fileExtLower(item.filePath);
|
||||
}
|
||||
|
||||
List<UnifiedLibraryItem> _applyAdvancedFilters(
|
||||
List<UnifiedLibraryItem> items,
|
||||
) {
|
||||
@@ -1523,7 +1531,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
if (_filterFormat != null) {
|
||||
final ext = _fileExtLower(item.filePath);
|
||||
final ext = _itemFormatLower(item);
|
||||
if (ext != _filterFormat) return false;
|
||||
}
|
||||
|
||||
@@ -1656,8 +1664,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
||||
final formats = <String>{};
|
||||
for (final item in items) {
|
||||
final ext = _fileExtLower(item.filePath);
|
||||
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
|
||||
final ext = _itemFormatLower(item);
|
||||
if ([
|
||||
'flac',
|
||||
'alac',
|
||||
'mp3',
|
||||
'm4a',
|
||||
'aac',
|
||||
'eac3',
|
||||
'ac3',
|
||||
'ac4',
|
||||
'opus',
|
||||
'ogg',
|
||||
'wav',
|
||||
'aiff',
|
||||
].contains(ext)) {
|
||||
formats.add(ext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1611,6 +1611,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _displayFormatLabelForFile(String fileName) {
|
||||
final localFormat = _isLocalItem
|
||||
? normalizeOptionalString(_localLibraryItem?.format)
|
||||
: null;
|
||||
final raw =
|
||||
localFormat ??
|
||||
(fileName.contains('.') ? fileName.split('.').last : 'Unknown');
|
||||
final normalized = raw.toLowerCase().replaceAll('-', '_');
|
||||
return switch (normalized) {
|
||||
'flac' => 'FLAC',
|
||||
'alac' => 'ALAC',
|
||||
'eac3' || 'ec_3' => 'EAC3',
|
||||
'ac3' || 'ac_3' => 'AC3',
|
||||
'ac4' || 'ac_4' => 'AC4',
|
||||
'aac' || 'mp4a' => 'AAC',
|
||||
'm4a' => 'M4A',
|
||||
'mp3' => 'MP3',
|
||||
'opus' => 'Opus',
|
||||
'ogg' => 'OGG',
|
||||
_ => raw.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
bool _isBitrateFormatLabel(String label) {
|
||||
return const {
|
||||
'MP3',
|
||||
'OPUS',
|
||||
'OGG',
|
||||
'M4A',
|
||||
'AAC',
|
||||
'EAC3',
|
||||
'AC3',
|
||||
'AC4',
|
||||
}.contains(label.toUpperCase());
|
||||
}
|
||||
|
||||
Widget _buildFileInfoCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1619,9 +1655,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
final displayFilePath = _formatPathForDisplay(rawFilePath);
|
||||
final fileName = _extractFileNameFromPathOrUri(rawFilePath);
|
||||
final fileExtension = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: 'Unknown';
|
||||
final fileExtension = _displayFormatLabelForFile(fileName);
|
||||
final resolvedQuality = _displayAudioQuality;
|
||||
final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality);
|
||||
|
||||
@@ -1694,9 +1728,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if ((fileExtension == 'MP3' ||
|
||||
fileExtension == 'OPUS' ||
|
||||
fileExtension == 'OGG') &&
|
||||
if (_isBitrateFormatLabel(fileExtension) &&
|
||||
lossyBitrateLabel != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -1719,9 +1751,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
else if (_isLocalItem &&
|
||||
_localBitrate != null &&
|
||||
_localBitrate! > 0 &&
|
||||
(fileExtension == 'MP3' ||
|
||||
fileExtension == 'OPUS' ||
|
||||
fileExtension == 'OGG'))
|
||||
_isBitrateFormatLabel(fileExtension))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
@@ -3249,6 +3279,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return 'CUE+$audioFmt';
|
||||
}
|
||||
}
|
||||
if (_isLocalItem && _localLibraryItem != null) {
|
||||
final format = normalizeOptionalString(
|
||||
_localLibraryItem!.format,
|
||||
)?.toLowerCase().replaceAll('-', '_');
|
||||
switch (format) {
|
||||
case 'flac':
|
||||
return 'FLAC';
|
||||
case 'alac':
|
||||
case 'm4a':
|
||||
return 'M4A';
|
||||
case 'mp3':
|
||||
return 'MP3';
|
||||
case 'opus':
|
||||
case 'ogg':
|
||||
return 'Opus';
|
||||
}
|
||||
}
|
||||
final lower = cleanFilePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) return 'FLAC';
|
||||
if (lower.endsWith('.m4a')) return 'M4A';
|
||||
|
||||
@@ -34,7 +34,7 @@ class LocalLibraryItem {
|
||||
final String? composer;
|
||||
final String? label;
|
||||
final String? copyright;
|
||||
final String? format; // flac, mp3, opus, m4a
|
||||
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
required this.id,
|
||||
@@ -1299,6 +1299,7 @@ class LibraryDatabase {
|
||||
args,
|
||||
request,
|
||||
filePathExpr: 'h.file_path',
|
||||
formatExpr: null,
|
||||
qualityExpr: 'h.quality',
|
||||
bitDepthExpr: 'h.bit_depth',
|
||||
artistExpr: 'h.artist_name',
|
||||
@@ -1335,6 +1336,7 @@ class LibraryDatabase {
|
||||
args,
|
||||
request,
|
||||
filePathExpr: 'l.file_path',
|
||||
formatExpr: 'l.format',
|
||||
qualityExpr: 'NULL',
|
||||
bitDepthExpr: 'l.bit_depth',
|
||||
artistExpr: 'l.artist_name',
|
||||
@@ -1353,6 +1355,7 @@ class LibraryDatabase {
|
||||
List<Object?> args,
|
||||
QueueLibraryDbQuery request, {
|
||||
required String filePathExpr,
|
||||
required String? formatExpr,
|
||||
required String qualityExpr,
|
||||
required String bitDepthExpr,
|
||||
required String artistExpr,
|
||||
@@ -1385,8 +1388,15 @@ class LibraryDatabase {
|
||||
|
||||
final format = request.format?.trim().toLowerCase();
|
||||
if (format != null && format.isNotEmpty) {
|
||||
where.add('LOWER($filePathExpr) LIKE ?');
|
||||
args.add('%.$format');
|
||||
if (formatExpr == null) {
|
||||
where.add('LOWER($filePathExpr) LIKE ?');
|
||||
args.add('%.$format');
|
||||
} else {
|
||||
where.add(
|
||||
'(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)',
|
||||
);
|
||||
args.addAll([format, '%.$format']);
|
||||
}
|
||||
}
|
||||
|
||||
final metadata = request.metadata?.trim();
|
||||
|
||||
Reference in New Issue
Block a user