From f7439c6793a5891416d7e677e59c018e00b382ef Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 11 Aug 2026 16:56:12 +0700 Subject: [PATCH] feat(library): add combined quality label #511 --- lib/models/settings.dart | 4 +- lib/providers/settings_provider.dart | 10 ++- .../settings/library_settings_page.dart | 10 +++ lib/utils/audio_quality_badge_policy.dart | 69 +++++++++++++++---- test/audio_quality_badge_policy_test.dart | 51 +++++++++++++- test/models_and_utils_test.dart | 8 +-- 6 files changed, 130 insertions(+), 22 deletions(-) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e0836f6c..0fdb6010 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 10ee3f6e..0fafdb35 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -380,9 +380,13 @@ class SettingsNotifier extends Notifier { } 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) { diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index e3b9edb6..bc8f6d15 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -404,6 +404,10 @@ class _LibrarySettingsPageState extends ConsumerState { 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 { '${context.l10n.audioAnalysisBitDepth} & ' '${context.l10n.audioAnalysisSampleRate}', ), + ( + AppSettings.libraryQualityLabelBitDepthBitrate, + Icons.multiline_chart_rounded, + '${context.l10n.audioAnalysisBitDepth} & ' + '${context.l10n.trackConvertBitrate}', + ), ]; showModalBottomSheet( context: context, diff --git a/lib/utils/audio_quality_badge_policy.dart b/lib/utils/audio_quality_badge_policy.dart index b38321b7..bb15e347 100644 --- a/lib/utils/audio_quality_badge_policy.dart +++ b/lib/utils/audio_quality_badge_policy.dart @@ -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 diff --git a/test/audio_quality_badge_policy_test.dart b/test/audio_quality_badge_policy_test.dart index 6a9aa0e5..c3700a8b 100644 --- a/test/audio_quality_badge_policy_test.dart +++ b/test/audio_quality_badge_policy_test.dart @@ -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', ); }); }); diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 4d4e4aab..b0ff77aa 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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);