feat(library): add combined quality label #511

This commit is contained in:
zarzet
2026-08-11 16:56:12 +07:00
parent 6dbca64a80
commit f7439c6793
6 changed files with 130 additions and 22 deletions
+3 -1
View File
@@ -8,6 +8,7 @@ class AppSettings {
static const String homeFeedProviderOff = '__off__';
static const String libraryQualityLabelBitrate = 'bitrate';
static const String libraryQualityLabelBitDepth = 'bit_depth';
static const String libraryQualityLabelBitDepthBitrate = 'bit_depth_bitrate';
final String defaultService;
final String audioQuality;
@@ -41,7 +42,8 @@ class AppSettings {
/// 'last' (keep last used), 'all', 'albums', 'singles', or 'playlists'.
final String defaultLibraryView;
/// Library badge text: measured bitrate or the legacy bit depth/sample rate.
/// Library badge text: measured bitrate, bit depth/sample rate, or the
/// combined bit depth/bitrate label.
final String libraryQualityLabelMode;
final bool askQualityBeforeDownload;
final bool enableLogging;
+7 -3
View File
@@ -380,9 +380,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
String _normalizeLibraryQualityLabelMode(String value) {
return value == AppSettings.libraryQualityLabelBitDepth
? AppSettings.libraryQualityLabelBitDepth
: AppSettings.libraryQualityLabelBitrate;
return switch (value) {
AppSettings.libraryQualityLabelBitDepth =>
AppSettings.libraryQualityLabelBitDepth,
AppSettings.libraryQualityLabelBitDepthBitrate =>
AppSettings.libraryQualityLabelBitDepthBitrate,
_ => AppSettings.libraryQualityLabelBitrate,
};
}
String _normalizeExtensionVerificationBrowserMode(String value) {
@@ -404,6 +404,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return '${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.audioAnalysisSampleRate}';
}
if (mode == AppSettings.libraryQualityLabelBitDepthBitrate) {
return '${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.trackConvertBitrate}';
}
return context.l10n.trackConvertBitrate;
}
@@ -421,6 +425,12 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
'${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.audioAnalysisSampleRate}',
),
(
AppSettings.libraryQualityLabelBitDepthBitrate,
Icons.multiline_chart_rounded,
'${context.l10n.audioAnalysisBitDepth} & '
'${context.l10n.trackConvertBitrate}',
),
];
showModalBottomSheet<void>(
context: context,
+56 -13
View File
@@ -5,9 +5,13 @@ import 'package:spotiflac_android/utils/string_utils.dart';
const highQualityBadgeBitrateThresholdKbps = 900;
String normalizeLibraryQualityLabelMode(String? mode) {
return mode == AppSettings.libraryQualityLabelBitDepth
? AppSettings.libraryQualityLabelBitDepth
: AppSettings.libraryQualityLabelBitrate;
return switch (mode) {
AppSettings.libraryQualityLabelBitDepth =>
AppSettings.libraryQualityLabelBitDepth,
AppSettings.libraryQualityLabelBitDepthBitrate =>
AppSettings.libraryQualityLabelBitDepthBitrate,
_ => AppSettings.libraryQualityLabelBitrate,
};
}
/// Builds a Library label from metadata already held in memory. Lossy formats
@@ -21,22 +25,61 @@ String? buildLibraryAudioQualityLabel({
int? sampleRate,
String? storedQuality,
}) {
final bitrateLabel = bitrateKbps != null && bitrateKbps > 0
? buildDisplayAudioQuality(bitrateKbps: bitrateKbps, format: format)
final stored = normalizeOptionalString(storedQuality);
final storedBitrate = _bitrateFromStoredQuality(stored);
final effectiveBitrate = bitrateKbps != null && bitrateKbps > 0
? bitrateKbps
: storedBitrate;
final bitrateLabel = effectiveBitrate != null
? buildDisplayAudioQuality(bitrateKbps: effectiveBitrate, format: format)
: null;
final bitDepthLabel =
bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0
? buildDisplayAudioQuality(bitDepth: bitDepth, sampleRate: sampleRate)
: null;
final fallback = normalizeOptionalString(storedQuality);
final normalizedMode = normalizeLibraryQualityLabelMode(mode);
final useBitDepth =
normalizeLibraryQualityLabelMode(mode) ==
AppSettings.libraryQualityLabelBitDepth &&
!isLossyAudioFormat(format);
return useBitDepth
? bitDepthLabel ?? bitrateLabel ?? fallback
: bitrateLabel ?? bitDepthLabel ?? fallback;
if (isLossyAudioFormat(format)) {
return bitrateLabel;
}
return switch (normalizedMode) {
AppSettings.libraryQualityLabelBitDepth =>
bitDepthLabel ?? bitrateLabel ?? stored,
AppSettings.libraryQualityLabelBitDepthBitrate =>
_buildBitDepthBitrateLabel(
bitDepth: bitDepth,
bitrateKbps: effectiveBitrate,
) ??
bitrateLabel ??
bitDepthLabel ??
stored,
// Do not display bit depth/sample rate while the user selected bitrate.
// Legacy rows are backfilled separately; a stored measured bitrate remains
// usable while that migration completes.
_ => bitrateLabel,
};
}
int? _bitrateFromStoredQuality(String? quality) {
final match = RegExp(
r'\b(\d+(?:\.\d+)?)\s*(k(?:bps)?|mbps)\b',
caseSensitive: false,
).firstMatch(quality ?? '');
final value = double.tryParse(match?.group(1) ?? '');
if (value == null || value <= 0) return null;
final unit = match?.group(2)?.toLowerCase();
return unit == 'mbps' ? (value * 1000).round() : value.round();
}
String? _buildBitDepthBitrateLabel({int? bitDepth, int? bitrateKbps}) {
if (bitDepth == null ||
bitDepth <= 0 ||
bitrateKbps == null ||
bitrateKbps <= 0) {
return null;
}
return '$bitDepth-bit/${bitrateKbps}kbps';
}
/// Preserves the highlighted color used by legacy 24-bit Library badges while
+50 -1
View File
@@ -40,6 +40,12 @@ void main() {
normalizeLibraryQualityLabelMode('unsupported'),
AppSettings.libraryQualityLabelBitrate,
);
expect(
normalizeLibraryQualityLabelMode(
AppSettings.libraryQualityLabelBitDepthBitrate,
),
AppSettings.libraryQualityLabelBitDepthBitrate,
);
});
test('restores legacy bit depth and sample rate labels', () {
@@ -68,6 +74,32 @@ void main() {
);
});
test('combines bit depth and measured bitrate for lossless audio', () {
expect(
buildLibraryAudioQualityLabel(
mode: AppSettings.libraryQualityLabelBitDepthBitrate,
format: 'flac',
bitrateKbps: 1760,
bitDepth: 24,
sampleRate: 48000,
),
'24-bit/1760kbps',
);
});
test('keeps lossy audio on bitrate in the combined mode', () {
expect(
buildLibraryAudioQualityLabel(
mode: AppSettings.libraryQualityLabelBitDepthBitrate,
format: 'aac',
bitrateKbps: 256,
bitDepth: 16,
sampleRate: 44100,
),
'AAC 256kbps',
);
});
test('falls back when the preferred metadata is unavailable', () {
expect(
buildLibraryAudioQualityLabel(
@@ -83,7 +115,24 @@ void main() {
mode: AppSettings.libraryQualityLabelBitrate,
storedQuality: '24-bit/96kHz',
),
'24-bit/96kHz',
isNull,
);
expect(
buildLibraryAudioQualityLabel(
mode: AppSettings.libraryQualityLabelBitrate,
format: 'flac',
storedQuality: 'FLAC 1411kbps',
),
'FLAC 1411kbps',
);
expect(
buildLibraryAudioQualityLabel(
mode: AppSettings.libraryQualityLabelBitDepthBitrate,
format: 'flac',
bitDepth: 24,
storedQuality: 'FLAC 1411kbps',
),
'24-bit/1411kbps',
);
});
});
+4 -4
View File
@@ -694,7 +694,7 @@ void main() {
lyricsAppleElrcWordSync: true,
deduplicateDownloads: false,
allowQualityVariants: true,
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth,
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepthBitrate,
clearDownloadFallbackExtensionIds: true,
clearSearchProvider: true,
clearHomeFeedProvider: true,
@@ -708,7 +708,7 @@ void main() {
expect(updated.allowQualityVariants, isTrue);
expect(
updated.libraryQualityLabelMode,
AppSettings.libraryQualityLabelBitDepth,
AppSettings.libraryQualityLabelBitDepthBitrate,
);
expect(updated.downloadFallbackExtensionIds, isNull);
expect(updated.searchProvider, isNull);
@@ -739,7 +739,7 @@ void main() {
autoConvertDownloads: true,
autoConvertFormat: 'opus',
autoConvertBitrate: '192k',
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth,
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepthBitrate,
);
final decoded = AppSettings.fromJson(settings.toJson());
@@ -761,7 +761,7 @@ void main() {
expect(decoded.lastSeenVersion, '4.5.0');
expect(
decoded.libraryQualityLabelMode,
AppSettings.libraryQualityLabelBitDepth,
AppSettings.libraryQualityLabelBitDepthBitrate,
);
expect(decoded.deduplicateDownloads, isFalse);
expect(decoded.allowQualityVariants, isTrue);