mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-10 06:28:44 +02:00
fix: show lossy audio bitrate in quality labels
This commit is contained in:
@@ -110,6 +110,7 @@ object NativeDownloadFinalizer {
|
||||
var quality: String,
|
||||
var bitDepth: Int?,
|
||||
var sampleRate: Int?,
|
||||
var bitrateKbps: Int? = null,
|
||||
var pendingExternalLrc: String? = null,
|
||||
var pendingExternalLrcFileName: String? = null,
|
||||
)
|
||||
@@ -170,6 +171,8 @@ object NativeDownloadFinalizer {
|
||||
quality = requestQuality(effectiveInput),
|
||||
bitDepth = optPositiveInt(result, "actual_bit_depth"),
|
||||
sampleRate = optPositiveInt(result, "actual_sample_rate"),
|
||||
bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
|
||||
?: optPositiveBitrateKbps(result, "actual_bitrate"),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -637,10 +640,19 @@ object NativeDownloadFinalizer {
|
||||
state.sampleRate = sampleRate
|
||||
result.put("actual_sample_rate", sampleRate)
|
||||
}
|
||||
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
|
||||
?: optPositiveBitrateKbps(metadata, "bit_rate")
|
||||
if (bitrateKbps != null) {
|
||||
state.bitrateKbps = bitrateKbps
|
||||
result.put("bitrate", bitrateKbps)
|
||||
}
|
||||
|
||||
val displayQuality = displayAudioQuality(
|
||||
filePath = state.filePath,
|
||||
fileName = state.fileName,
|
||||
bitDepth = state.bitDepth,
|
||||
sampleRate = state.sampleRate,
|
||||
bitrateKbps = state.bitrateKbps,
|
||||
storedQuality = state.quality,
|
||||
)
|
||||
if (displayQuality != null) {
|
||||
@@ -672,14 +684,64 @@ object NativeDownloadFinalizer {
|
||||
lowerName.endsWith(".ogg")
|
||||
}
|
||||
|
||||
private fun displayAudioQuality(bitDepth: Int?, sampleRate: Int?, storedQuality: String?): String? {
|
||||
private fun displayAudioQuality(
|
||||
filePath: String,
|
||||
fileName: String,
|
||||
bitDepth: Int?,
|
||||
sampleRate: Int?,
|
||||
bitrateKbps: Int?,
|
||||
storedQuality: String?,
|
||||
): String? {
|
||||
val format = audioFormatForPath(filePath, fileName)
|
||||
if (format == "OPUS" ||
|
||||
format == "MP3" ||
|
||||
format == "AAC" ||
|
||||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
|
||||
) {
|
||||
return if (bitrateKbps != null && bitrateKbps > 0) {
|
||||
"$format ${bitrateKbps}kbps"
|
||||
} else {
|
||||
nonPlaceholderQuality(storedQuality) ?: format
|
||||
}
|
||||
}
|
||||
|
||||
if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) {
|
||||
val khz = sampleRate / 1000.0
|
||||
val precision = if (sampleRate % 1000 == 0) 0 else 1
|
||||
val sampleRateLabel = "%.${precision}f".format(Locale.US, khz)
|
||||
return "$bitDepth-bit/${sampleRateLabel}kHz"
|
||||
}
|
||||
return normalizeOptional(storedQuality)
|
||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
||||
}
|
||||
|
||||
private fun audioFormatForPath(filePath: String, fileName: String): String? {
|
||||
for (candidate in listOf(filePath, fileName)) {
|
||||
val lower = candidate.trim().lowercase(Locale.ROOT)
|
||||
when {
|
||||
lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS"
|
||||
lower.endsWith(".mp3") -> return "MP3"
|
||||
lower.endsWith(".aac") -> return "AAC"
|
||||
lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun nonPlaceholderQuality(quality: String?): String? {
|
||||
val normalized = normalizeOptional(quality) ?: return null
|
||||
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
|
||||
val placeholders = setOf(
|
||||
"best",
|
||||
"lossless",
|
||||
"hi_res",
|
||||
"hires",
|
||||
"hi_res_lossless",
|
||||
"hires_lossless",
|
||||
"high",
|
||||
"cd",
|
||||
"flac_best_available",
|
||||
)
|
||||
return if (placeholders.contains(key)) null else normalized
|
||||
}
|
||||
|
||||
private fun writeExternalLrc(context: Context, input: FinalizeInput, state: FinalizeState) {
|
||||
@@ -1898,6 +1960,15 @@ object NativeDownloadFinalizer {
|
||||
return if (value > 0) value else null
|
||||
}
|
||||
|
||||
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
|
||||
val value = optPositiveInt(obj, key) ?: return null
|
||||
return if (value >= 10000) {
|
||||
Math.round(value / 1000.0).toInt()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
|
||||
val value = if (primary > 0) primary else fallback
|
||||
return if (value > 0) value else null
|
||||
|
||||
@@ -1157,6 +1157,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
@@ -1257,6 +1260,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else if isOgg {
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
@@ -1289,6 +1295,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else if isApe || isWv || isMpc {
|
||||
// APE, WavPack, Musepack: read APEv2 tags
|
||||
|
||||
@@ -34,6 +34,76 @@ final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
|
||||
final _multiWhitespaceRegex = RegExp(r'\s+');
|
||||
final _multiUnderscoreRegex = RegExp(r'_+');
|
||||
|
||||
int? _readPositiveIntValue(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
int? _readPositiveBitrateKbps(dynamic value) {
|
||||
final parsed = _readPositiveIntValue(value);
|
||||
if (parsed == null) return null;
|
||||
return parsed >= 10000 ? (parsed / 1000).round() : parsed;
|
||||
}
|
||||
|
||||
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 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? _resolveDisplayQuality({
|
||||
required String? filePath,
|
||||
String? fileName,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrateKbps,
|
||||
String? storedQuality,
|
||||
}) {
|
||||
final format = _audioFormatForPath(filePath, fileName: fileName);
|
||||
if (format == 'OPUS' ||
|
||||
format == 'MP3' ||
|
||||
format == 'AAC' ||
|
||||
(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,
|
||||
);
|
||||
}
|
||||
|
||||
/// log10 helper using dart:math's natural log.
|
||||
double _log10(num x) => log(x) / ln10;
|
||||
final _yearRegex = RegExp(r'^(\d{4})');
|
||||
@@ -689,9 +759,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
|
||||
final bitDepth = _readPositiveInt(result['bit_depth']);
|
||||
final sampleRate = _readPositiveInt(result['sample_rate']);
|
||||
final quality = buildDisplayAudioQuality(
|
||||
final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']);
|
||||
final quality = _resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrateKbps: bitrateKbps,
|
||||
storedQuality: fallbackQuality,
|
||||
);
|
||||
final composer = normalizeOptionalString(result['composer']?.toString());
|
||||
@@ -704,6 +777,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
if (quality == null &&
|
||||
bitDepth == null &&
|
||||
sampleRate == null &&
|
||||
bitrateKbps == null &&
|
||||
composer == null &&
|
||||
duration == null &&
|
||||
trackNumber == null &&
|
||||
@@ -717,6 +791,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
'quality': quality,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'bitrateKbps': bitrateKbps,
|
||||
'composer': composer,
|
||||
'duration': duration,
|
||||
'trackNumber': trackNumber,
|
||||
@@ -5532,7 +5607,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
var actualQuality = context.quality;
|
||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
final resolvedQuality = _resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
bitDepth: actualBitDepth,
|
||||
sampleRate: actualSampleRate,
|
||||
storedQuality: actualQuality,
|
||||
@@ -7931,6 +8007,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendTotalDiscs = result['total_discs'] as int?;
|
||||
final backendBitDepth = result['actual_bit_depth'] as int?;
|
||||
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||
final backendBitrateKbps = _readPositiveBitrateKbps(
|
||||
result['bitrate'] ?? result['actual_bitrate'],
|
||||
);
|
||||
final backendISRC = result['isrc'] as String?;
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
@@ -7951,6 +8030,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
int? finalBitDepth = backendBitDepth;
|
||||
int? finalSampleRate = backendSampleRate;
|
||||
int? finalBitrateKbps = backendBitrateKbps;
|
||||
final lowerFilePath = filePath.toLowerCase();
|
||||
final canProbeFinalMetadata =
|
||||
filePath.startsWith('content://') ||
|
||||
@@ -7979,10 +8059,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (probedSampleRate != null && probedSampleRate > 0) {
|
||||
finalSampleRate = probedSampleRate;
|
||||
}
|
||||
final probedBitrateKbps = _readPositiveBitrateKbps(
|
||||
metadata['bitrate'] ?? metadata['bit_rate'],
|
||||
);
|
||||
if (probedBitrateKbps != null && probedBitrateKbps > 0) {
|
||||
finalBitrateKbps = probedBitrateKbps;
|
||||
}
|
||||
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
final resolvedQuality = _resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
fileName: finalSafFileName,
|
||||
bitDepth: finalBitDepth,
|
||||
sampleRate: finalSampleRate,
|
||||
bitrateKbps: finalBitrateKbps,
|
||||
storedQuality: actualQuality,
|
||||
);
|
||||
if (resolvedQuality != null) {
|
||||
|
||||
Reference in New Issue
Block a user