mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
245 lines
7.3 KiB
Dart
245 lines
7.3 KiB
Dart
import 'package:spotiflac_android/utils/int_utils.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
|
|
/// Audio format/quality helpers shared by the download queue and history
|
|
/// providers.
|
|
int? readPositiveBitrateKbps(dynamic value) {
|
|
final parsed = readPositiveInt(value);
|
|
if (parsed == null) return null;
|
|
final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed;
|
|
return kbps >= 16 ? kbps : null;
|
|
}
|
|
|
|
String? audioFormatForPath(String? filePath, {String? fileName}) {
|
|
final candidates = <String>[?filePath, ?fileName];
|
|
for (final candidate in candidates) {
|
|
final lower = candidate.trim().toLowerCase();
|
|
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'OPUS';
|
|
if (lower.endsWith('.mp3')) return 'MP3';
|
|
if (lower.endsWith('.aac')) return 'AAC';
|
|
if (lower.endsWith('.m4a') || lower.endsWith('.mp4')) return 'M4A';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? nonPlaceholderQuality(String? quality) {
|
|
final normalized = normalizeOptionalString(quality);
|
|
if (normalized == null || isPlaceholderQualityLabel(normalized)) {
|
|
return null;
|
|
}
|
|
final bitrateMatch = RegExp(
|
|
r'\b(\d+)\s*kbps\b',
|
|
caseSensitive: false,
|
|
).firstMatch(normalized);
|
|
if (bitrateMatch != null) {
|
|
final bitrate = int.tryParse(bitrateMatch.group(1) ?? '');
|
|
if (bitrate != null && bitrate < 16) return null;
|
|
}
|
|
final lower = normalized.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_');
|
|
const requestedLosslessLabels = {
|
|
'hi_res_lossless',
|
|
'hires_lossless',
|
|
'hi_res',
|
|
'hires',
|
|
'flac_best_available',
|
|
};
|
|
if (requestedLosslessLabels.contains(lower)) return null;
|
|
return normalized;
|
|
}
|
|
|
|
String? normalizeAudioFormatValue(String? value) {
|
|
final normalized = normalizeOptionalString(
|
|
value,
|
|
)?.toLowerCase().replaceAll('-', '_');
|
|
return switch (normalized) {
|
|
'flac' => 'flac',
|
|
'alac' => 'alac',
|
|
'wav' || 'wave' => 'wav',
|
|
'aiff' || 'aif' || 'aifc' => 'aiff',
|
|
'aac' || 'mp4a' => 'aac',
|
|
'eac3' || 'ec_3' => 'eac3',
|
|
'ac3' || 'ac_3' => 'ac3',
|
|
'ac4' || 'ac_4' => 'ac4',
|
|
'mp3' => 'mp3',
|
|
'opus' || 'ogg' => 'opus',
|
|
'm4a' || 'mp4' => 'm4a',
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
/// Resolves the actual audio codec reported by native metadata probing, while
|
|
/// falling back to the container format when the codec is absent or generic.
|
|
///
|
|
/// This distinction matters for MP4/M4A files: the same container may hold
|
|
/// AAC, ALAC, Dolby, or other codecs.
|
|
String? detectedAudioFormatFromMetadata(Map<String, dynamic> metadata) {
|
|
final codec = normalizeAudioFormatValue(metadata['audio_codec']?.toString());
|
|
if (codec != null) return codec;
|
|
return normalizeAudioFormatValue(metadata['format']?.toString());
|
|
}
|
|
|
|
bool isLossyAudioFormat(String? value) {
|
|
return const {
|
|
'aac',
|
|
'eac3',
|
|
'ac3',
|
|
'ac4',
|
|
'mp3',
|
|
'opus',
|
|
'm4a',
|
|
}.contains(normalizeAudioFormatValue(value));
|
|
}
|
|
|
|
/// Returns a provider-independent quality label suitable for a filename.
|
|
///
|
|
/// Requested labels such as LOSSLESS and HI_RES are intentionally ignored:
|
|
/// they describe provider intent, not the audio that was actually written.
|
|
String? buildQualityVariantFilenameLabel({
|
|
String? detectedFormat,
|
|
int? bitDepth,
|
|
int? sampleRate,
|
|
int? bitrateKbps,
|
|
String? measuredQuality,
|
|
}) {
|
|
if (isLossyAudioFormat(detectedFormat)) {
|
|
final effectiveBitrate =
|
|
bitrateKbps ?? _bitrateFromQuality(measuredQuality);
|
|
return effectiveBitrate != null && effectiveBitrate >= 16
|
|
? '${effectiveBitrate}kbps'
|
|
: null;
|
|
}
|
|
|
|
final measured = _losslessSpecsFromQuality(measuredQuality);
|
|
final effectiveBitDepth = bitDepth ?? measured?.$1;
|
|
final effectiveSampleRate = sampleRate ?? measured?.$2;
|
|
if (effectiveBitDepth == null ||
|
|
effectiveBitDepth <= 0 ||
|
|
effectiveSampleRate == null ||
|
|
effectiveSampleRate <= 0) {
|
|
return null;
|
|
}
|
|
return '${effectiveBitDepth}bit-${formatSampleRateKHz(effectiveSampleRate)}';
|
|
}
|
|
|
|
int? _bitrateFromQuality(String? quality) {
|
|
final match = RegExp(
|
|
r'\b(\d+)\s*kbps\b',
|
|
caseSensitive: false,
|
|
).firstMatch(quality ?? '');
|
|
final value = int.tryParse(match?.group(1) ?? '');
|
|
return value != null && value >= 16 ? value : null;
|
|
}
|
|
|
|
(int, int)? _losslessSpecsFromQuality(String? quality) {
|
|
final match = RegExp(
|
|
r'\b(\d+)\s*(?:-|\s)?bit\s*[/_-]\s*(\d+(?:\.\d+)?)\s*k?hz\b',
|
|
caseSensitive: false,
|
|
).firstMatch(quality ?? '');
|
|
final bitDepth = int.tryParse(match?.group(1) ?? '');
|
|
final rate = double.tryParse(match?.group(2) ?? '');
|
|
if (bitDepth == null || bitDepth <= 0 || rate == null || rate <= 0) {
|
|
return null;
|
|
}
|
|
final sampleRate = rate < 1000 ? (rate * 1000).round() : rate.round();
|
|
return (bitDepth, sampleRate);
|
|
}
|
|
|
|
String qualityVariantStagingLabel(String itemId) {
|
|
var hash = 0x811c9dc5;
|
|
for (final byte in itemId.codeUnits) {
|
|
hash ^= byte;
|
|
hash = (hash * 0x01000193) & 0xffffffff;
|
|
}
|
|
return 'qv_${hash.toRadixString(16).padLeft(8, '0')}';
|
|
}
|
|
|
|
String applyQualityVariantFilenameLabel({
|
|
required String fileName,
|
|
required String stagingLabel,
|
|
required String qualityLabel,
|
|
}) {
|
|
if (stagingLabel.isNotEmpty && fileName.contains(stagingLabel)) {
|
|
return fileName.replaceAll(stagingLabel, qualityLabel);
|
|
}
|
|
if (fileName.contains(qualityLabel)) {
|
|
return fileName;
|
|
}
|
|
final dotIndex = fileName.lastIndexOf('.');
|
|
final hasExtension = dotIndex > 0;
|
|
final stem = hasExtension ? fileName.substring(0, dotIndex) : fileName;
|
|
final extension = hasExtension ? fileName.substring(dotIndex) : '';
|
|
return '$stem - $qualityLabel$extension';
|
|
}
|
|
|
|
String lossyFormatForSetting(String value) {
|
|
final normalized = value.trim().toLowerCase();
|
|
if (normalized.startsWith('opus')) return 'opus';
|
|
if (normalized.startsWith('aac') || normalized.startsWith('m4a')) {
|
|
return 'aac';
|
|
}
|
|
return 'mp3';
|
|
}
|
|
|
|
String lossyExtensionForFormat(String format) {
|
|
return switch (format) {
|
|
'opus' => '.opus',
|
|
'aac' => '.m4a',
|
|
_ => '.mp3',
|
|
};
|
|
}
|
|
|
|
String metadataFormatForLossyFormat(String format) {
|
|
return format == 'aac' ? 'm4a' : format;
|
|
}
|
|
|
|
String displayFormatForLossyFormat(String format) {
|
|
return format == 'aac' ? 'AAC' : format.toUpperCase();
|
|
}
|
|
|
|
String? resolveDisplayQuality({
|
|
required String? filePath,
|
|
String? fileName,
|
|
String? detectedFormat,
|
|
int? bitDepth,
|
|
int? sampleRate,
|
|
int? bitrateKbps,
|
|
String? storedQuality,
|
|
}) {
|
|
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) ??
|
|
format;
|
|
}
|
|
return buildDisplayAudioQuality(
|
|
bitDepth: bitDepth,
|
|
sampleRate: sampleRate,
|
|
storedQuality: nonPlaceholderQuality(storedQuality) ?? storedQuality,
|
|
);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|