diff --git a/lib/models/settings.dart b/lib/models/settings.dart index f119fa84..66fc7657 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -6,6 +6,8 @@ part 'settings.g.dart'; @JsonSerializable() class AppSettings { static const String homeFeedProviderOff = '__off__'; + static const String libraryQualityLabelBitrate = 'bitrate'; + static const String libraryQualityLabelBitDepth = 'bit_depth'; final String defaultService; final String audioQuality; @@ -38,6 +40,9 @@ class AppSettings { /// Library view opened when switching to the Library tab: /// 'last' (keep last used), 'all', 'albums', 'singles', or 'playlists'. final String defaultLibraryView; + + /// Library badge text: measured bitrate or the legacy bit depth/sample rate. + final String libraryQualityLabelMode; final bool askQualityBeforeDownload; final bool enableLogging; final bool useExtensionProviders; @@ -137,6 +142,7 @@ class AppSettings { this.historyViewMode = 'grid', this.historyFilterMode = 'all', this.defaultLibraryView = 'last', + this.libraryQualityLabelMode = libraryQualityLabelBitrate, this.askQualityBeforeDownload = true, this.enableLogging = false, this.useExtensionProviders = true, @@ -208,6 +214,7 @@ class AppSettings { String? historyViewMode, String? historyFilterMode, String? defaultLibraryView, + String? libraryQualityLabelMode, bool? askQualityBeforeDownload, bool? enableLogging, bool? useExtensionProviders, @@ -286,6 +293,8 @@ class AppSettings { historyViewMode: historyViewMode ?? this.historyViewMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode, defaultLibraryView: defaultLibraryView ?? this.defaultLibraryView, + libraryQualityLabelMode: + libraryQualityLabelMode ?? this.libraryQualityLabelMode, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, enableLogging: enableLogging ?? this.enableLogging, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 0704be88..39f48932 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -34,6 +34,9 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyFilterMode: json['historyFilterMode'] as String? ?? 'all', defaultLibraryView: json['defaultLibraryView'] as String? ?? 'last', + libraryQualityLabelMode: + json['libraryQualityLabelMode'] as String? ?? + AppSettings.libraryQualityLabelBitrate, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, @@ -124,6 +127,7 @@ Map _$AppSettingsToJson( 'historyViewMode': instance.historyViewMode, 'historyFilterMode': instance.historyFilterMode, 'defaultLibraryView': instance.defaultLibraryView, + 'libraryQualityLabelMode': instance.libraryQualityLabelMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'enableLogging': instance.enableLogging, 'useExtensionProviders': instance.useExtensionProviders, diff --git a/lib/models/unified_library_item.dart b/lib/models/unified_library_item.dart index e9f480ef..4eccd54f 100644 --- a/lib/models/unified_library_item.dart +++ b/lib/models/unified_library_item.dart @@ -1,6 +1,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; enum LibraryItemSource { downloaded, local } @@ -103,6 +104,34 @@ class UnifiedLibraryItem { coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); + String? qualityForMode(String mode) { + final history = historyItem; + if (history != null) { + return buildLibraryAudioQualityLabel( + mode: mode, + format: history.format, + bitrateKbps: history.bitrate, + bitDepth: history.bitDepth, + sampleRate: history.sampleRate, + storedQuality: history.quality ?? quality, + ); + } + + final local = localItem; + if (local != null) { + return buildLibraryAudioQualityLabel( + mode: mode, + format: local.format, + bitrateKbps: local.bitrate, + bitDepth: local.bitDepth, + sampleRate: local.sampleRate, + storedQuality: quality, + ); + } + + return quality; + } + String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist; String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 8643534b..dd086150 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -159,6 +159,9 @@ class SettingsNotifier extends Notifier { defaultLibraryView: _normalizeDefaultLibraryView( loaded.defaultLibraryView, ), + libraryQualityLabelMode: _normalizeLibraryQualityLabelMode( + loaded.libraryQualityLabelMode, + ), defaultService: loaded.defaultService, searchProvider: loaded.searchProvider, extensionVerificationBrowserMode: @@ -369,6 +372,12 @@ class SettingsNotifier extends Notifier { return 'last'; } + String _normalizeLibraryQualityLabelMode(String value) { + return value == AppSettings.libraryQualityLabelBitDepth + ? AppSettings.libraryQualityLabelBitDepth + : AppSettings.libraryQualityLabelBitrate; + } + String _normalizeExtensionVerificationBrowserMode(String value) { final normalized = value.trim().toLowerCase(); if (_extensionVerificationBrowserModeValues.contains(normalized)) { @@ -630,6 +639,13 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setLibraryQualityLabelMode(String mode) { + state = state.copyWith( + libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(mode), + ); + _saveSettings(); + } + void setHomeFeedProvider(String? provider) { if (provider == null || provider.isEmpty) { state = state.copyWith(clearHomeFeedProvider: true); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 8bb3958d..13a38ead 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -10,12 +10,14 @@ import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; @@ -61,6 +63,7 @@ class _DownloadedAlbumScreenState extends ConsumerState List? _sortedDiscNumbersCache; List? _commonQualitySourceCache; String? _commonQualityCache; + String? _commonQualityModeCache; List? _embeddedCoverSourceCache; String? _embeddedCoverPathCache; bool _embeddedCoverPathResolved = false; @@ -118,6 +121,7 @@ class _DownloadedAlbumScreenState extends ConsumerState _sortedDiscNumbersCache = null; _commonQualitySourceCache = null; _commonQualityCache = null; + _commonQualityModeCache = null; _embeddedCoverSourceCache = null; _embeddedCoverPathCache = null; _embeddedCoverPathResolved = false; @@ -233,6 +237,9 @@ class _DownloadedAlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final qualityLabelMode = ref.watch( + settingsProvider.select((s) => s.libraryQualityLabelMode), + ); final bottomPadding = MediaQuery.paddingOf(context).bottom; final bottomInset = context.navBarBottomInset; @@ -269,7 +276,7 @@ class _DownloadedAlbumScreenState extends ConsumerState scrollController: scrollController, isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, - appBar: _buildAppBar(context, colorScheme, tracks), + appBar: _buildAppBar(context, colorScheme, tracks, qualityLabelMode), trackList: _buildTrackList(context, colorScheme, tracks), bottomBar: _buildSelectionBottomBar( context, @@ -307,10 +314,11 @@ class _DownloadedAlbumScreenState extends ConsumerState BuildContext context, ColorScheme colorScheme, List tracks, + String qualityLabelMode, ) { final expandedHeight = calculateExpandedHeight(context); final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); - final commonQuality = _getCommonQuality(tracks); + final commonQuality = _getCommonQuality(tracks, qualityLabelMode); final cacheWidth = coverCacheWidthForViewport(context); final Widget background = embeddedCoverPath != null @@ -458,30 +466,44 @@ class _DownloadedAlbumScreenState extends ConsumerState ); } - String? _getCommonQuality(List tracks) { - if (identical(tracks, _commonQualitySourceCache)) { + String? _getCommonQuality(List tracks, String mode) { + if (identical(tracks, _commonQualitySourceCache) && + mode == _commonQualityModeCache) { return _commonQualityCache; } if (tracks.isEmpty) { _commonQualitySourceCache = tracks; + _commonQualityModeCache = mode; _commonQualityCache = null; return null; } - final firstQuality = tracks.first.quality; + String? label(DownloadHistoryItem track) => buildLibraryAudioQualityLabel( + mode: mode, + format: track.format, + bitrateKbps: track.bitrate, + bitDepth: track.bitDepth, + sampleRate: track.sampleRate, + storedQuality: track.quality, + ); + + final firstQuality = label(tracks.first); if (firstQuality == null) { _commonQualitySourceCache = tracks; + _commonQualityModeCache = mode; _commonQualityCache = null; return null; } for (final track in tracks) { - if (track.quality != firstQuality) { + if (label(track) != firstQuality) { _commonQualitySourceCache = tracks; + _commonQualityModeCache = mode; _commonQualityCache = null; return null; } } _commonQualitySourceCache = tracks; + _commonQualityModeCache = mode; _commonQualityCache = firstQuality; return firstQuality; } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index a531431d..6a6c6de2 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -70,6 +71,7 @@ class _LocalAlbumScreenState extends ConsumerState late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; String? _commonQualityCache; + String? _commonQualityModeCache; @override void initState() { @@ -105,7 +107,8 @@ class _LocalAlbumScreenState extends ConsumerState _discGroupsCache = _groupTracksByDisc(_sortedTracksCache); _sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort(); _hasMultipleDiscsCache = _discGroupsCache.length > 1; - _commonQualityCache = _computeCommonQuality(_sortedTracksCache); + _commonQualityCache = null; + _commonQualityModeCache = null; } Map> _groupTracksByDisc( @@ -167,6 +170,9 @@ class _LocalAlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final qualityLabelMode = ref.watch( + settingsProvider.select((s) => s.libraryQualityLabelMode), + ); final bottomPadding = MediaQuery.paddingOf(context).bottom; final bottomInset = context.navBarBottomInset; final tracks = _sortedTracksCache; @@ -184,7 +190,7 @@ class _LocalAlbumScreenState extends ConsumerState scrollController: scrollController, isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, - appBar: _buildAppBar(context, colorScheme), + appBar: _buildAppBar(context, colorScheme, qualityLabelMode), trackList: _buildTrackList(context, colorScheme, tracks), bottomBar: _buildSelectionBottomBar( context, @@ -197,7 +203,11 @@ class _LocalAlbumScreenState extends ConsumerState ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + String qualityLabelMode, + ) { final expandedHeight = calculateExpandedHeight(context); final cacheWidth = coverCacheWidthForViewport(context); @@ -261,7 +271,7 @@ class _LocalAlbumScreenState extends ConsumerState maxLines: 1, overflow: TextOverflow.ellipsis, ), - meta: _buildLocalHeaderMeta(context), + meta: _buildLocalHeaderMeta(context, qualityLabelMode), actions: AlbumPlayActions( playLabel: context.l10n.tooltipPlay, shuffleTooltip: context.l10n.actionShuffle, @@ -271,14 +281,14 @@ class _LocalAlbumScreenState extends ConsumerState ); } - Widget _buildLocalHeaderMeta(BuildContext context) { + Widget _buildLocalHeaderMeta(BuildContext context, String qualityLabelMode) { final tracks = _sortedTracksCache; final totalSeconds = tracks.fold( 0, (sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0), ); final totalMinutes = (totalSeconds / 60).round(); - final quality = _commonQualityCache; + final quality = _getCommonQuality(qualityLabelMode); return HeaderMetaRow( items: [ @@ -306,34 +316,27 @@ class _LocalAlbumScreenState extends ConsumerState await _openFile(tracks[Random().nextInt(tracks.length)]); } - String? _computeCommonQuality(List tracks) { + String? _getCommonQuality(String mode) { + if (_commonQualityModeCache == mode) return _commonQualityCache; + _commonQualityModeCache = mode; + _commonQualityCache = _computeCommonQuality(_sortedTracksCache, mode); + return _commonQualityCache; + } + + String? _computeCommonQuality(List tracks, String mode) { if (tracks.isEmpty) return null; - final first = tracks.first; + String? label(LocalLibraryItem track) => buildLibraryAudioQualityLabel( + mode: mode, + format: track.format, + bitrateKbps: track.bitrate, + bitDepth: track.bitDepth, + sampleRate: track.sampleRate, + ); - if (first.bitrate != null && first.bitrate! > 0) { - final fmt = first.format?.toUpperCase() ?? ''; - final firstBitrate = first.bitrate; - for (final track in tracks) { - if (track.bitrate != firstBitrate) { - return null; - } - } - return '$fmt ${firstBitrate}kbps'.trim(); - } - - if (first.bitDepth == null || - first.bitDepth == 0 || - first.sampleRate == null) { - return null; - } - - final firstQuality = - '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; + final firstQuality = label(tracks.first); + if (firstQuality == null) return null; for (final track in tracks) { - if (track.bitDepth != first.bitDepth || - track.sampleRate != first.sampleRate) { - return null; - } + if (label(track) != firstQuality) return null; } return firstQuality; } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index e872be71..23b81813 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -270,6 +271,7 @@ class _QueueTabState extends ConsumerState { String? _filterFormat; String? _filterMetadata; String _sortMode = 'latest'; + String _libraryQualityLabelMode = AppSettings.libraryQualityLabelBitrate; double _libraryGridExtent = _libraryGridDefaultExtent; double? _libraryGridScaleStartExtent; final Map _libraryPageOffsetByFilter = {}; @@ -1237,6 +1239,11 @@ class _QueueTabState extends ConsumerState { final historyFilterMode = ref.watch( settingsProvider.select((s) => s.historyFilterMode), ); + // Keep this mode out of the page-provider request: changing only badge + // text must not re-query the database or reset Library pagination. + _libraryQualityLabelMode = ref.watch( + settingsProvider.select((s) => s.libraryQualityLabelMode), + ); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); final countsRequest = _QueueLibraryCountsRequest( diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 19aa1bf1..f8698097 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -156,7 +156,9 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { final unifiedItem = historyItem == null ? null : UnifiedLibraryItem.fromDownloadHistory(historyItem); - final quality = unifiedItem?.quality ?? track.audioQuality; + final quality = + unifiedItem?.qualityForMode(_libraryQualityLabelMode) ?? + track.audioQuality; final cover = unifiedItem != null ? _buildUnifiedCoverImage(unifiedItem, colorScheme) : track.coverUrl != null @@ -246,7 +248,9 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { final unifiedItem = historyItem == null ? null : UnifiedLibraryItem.fromDownloadHistory(historyItem); - final quality = unifiedItem?.quality ?? track.audioQuality; + final quality = + unifiedItem?.qualityForMode(_libraryQualityLabelMode) ?? + track.audioQuality; final cover = unifiedItem != null ? _buildUnifiedCoverImage(unifiedItem, colorScheme, coverSize) : track.coverUrl != null diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 5ebba720..1ce4d9b0 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -877,6 +877,7 @@ extension _QueueTabItemWidgets on _QueueTabState { '${_QueueTabState._months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; final isDownloaded = item.source == LibraryItemSource.downloaded; + final quality = item.qualityForMode(_libraryQualityLabelMode); final sourceLabel = isDownloaded ? context.l10n.librarySourceDownloaded : context.l10n.librarySourceLocal; @@ -1004,13 +1005,12 @@ extension _QueueTabItemWidgets on _QueueTabState { ), ), ), - if (item.quality != null && - item.quality!.isNotEmpty) ...[ + if (quality != null && quality.isNotEmpty) ...[ const SizedBox(width: 8), _buildLibraryQualityBadge( context, colorScheme, - item.quality!, + quality, listStyle: true, ), ], @@ -1073,6 +1073,7 @@ extension _QueueTabItemWidgets on _QueueTabState { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); final isDownloaded = item.source == LibraryItemSource.downloaded; + final quality = item.qualityForMode(_libraryQualityLabelMode); return GestureDetector( onTap: _isSelectionMode @@ -1134,14 +1135,14 @@ extension _QueueTabItemWidgets on _QueueTabState { ), ), ), - if (item.quality != null && item.quality!.isNotEmpty) + if (quality != null && quality.isNotEmpty) Positioned( left: 4, top: 4, child: _buildLibraryQualityBadge( context, colorScheme, - item.quality!, + quality, ), ), if (!_isSelectionMode) diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index cb8b2b83..1148c6eb 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -402,6 +403,70 @@ class _LibrarySettingsPageState extends ConsumerState { ); } + String _getQualityLabelModeLabel(BuildContext context, String mode) { + if (mode == AppSettings.libraryQualityLabelBitDepth) { + return '${context.l10n.audioAnalysisBitDepth} & ' + '${context.l10n.audioAnalysisSampleRate}'; + } + return context.l10n.trackConvertBitrate; + } + + void _showQualityLabelModePicker(BuildContext context, String current) { + final colorScheme = Theme.of(context).colorScheme; + final options = [ + ( + AppSettings.libraryQualityLabelBitrate, + Icons.speed_rounded, + context.l10n.trackConvertBitrate, + ), + ( + AppSettings.libraryQualityLabelBitDepth, + Icons.graphic_eq_rounded, + '${context.l10n.audioAnalysisBitDepth} & ' + '${context.l10n.audioAnalysisSampleRate}', + ), + ]; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Text( + context.l10n.trackAudioQuality, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + for (final (value, icon, label) in options) + _AutoScanOption( + icon: icon, + title: label, + selected: current == value, + colorScheme: colorScheme, + onTap: () { + ref + .read(settingsProvider.notifier) + .setLibraryQualityLabelMode(value); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); @@ -448,6 +513,18 @@ class _LibrarySettingsPageState extends ConsumerState { context, settings.defaultLibraryView, ), + ), + SettingsItem( + icon: Icons.graphic_eq_rounded, + title: context.l10n.trackAudioQuality, + subtitle: _getQualityLabelModeLabel( + context, + settings.libraryQualityLabelMode, + ), + onTap: () => _showQualityLabelModePicker( + context, + settings.libraryQualityLabelMode, + ), showDivider: false, ), ], diff --git a/lib/utils/audio_quality_badge_policy.dart b/lib/utils/audio_quality_badge_policy.dart index 5da8f2dc..b38321b7 100644 --- a/lib/utils/audio_quality_badge_policy.dart +++ b/lib/utils/audio_quality_badge_policy.dart @@ -1,5 +1,44 @@ +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + const highQualityBadgeBitrateThresholdKbps = 900; +String normalizeLibraryQualityLabelMode(String? mode) { + return mode == AppSettings.libraryQualityLabelBitDepth + ? AppSettings.libraryQualityLabelBitDepth + : AppSettings.libraryQualityLabelBitrate; +} + +/// Builds a Library label from metadata already held in memory. Lossy formats +/// keep their bitrate label because bit depth is not a useful quality signal +/// for encoded MP3/AAC/Opus audio. +String? buildLibraryAudioQualityLabel({ + required String mode, + String? format, + int? bitrateKbps, + int? bitDepth, + int? sampleRate, + String? storedQuality, +}) { + final bitrateLabel = bitrateKbps != null && bitrateKbps > 0 + ? buildDisplayAudioQuality(bitrateKbps: bitrateKbps, format: format) + : null; + final bitDepthLabel = + bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0 + ? buildDisplayAudioQuality(bitDepth: bitDepth, sampleRate: sampleRate) + : null; + final fallback = normalizeOptionalString(storedQuality); + + final useBitDepth = + normalizeLibraryQualityLabelMode(mode) == + AppSettings.libraryQualityLabelBitDepth && + !isLossyAudioFormat(format); + return useBitDepth + ? bitDepthLabel ?? bitrateLabel ?? fallback + : bitrateLabel ?? bitDepthLabel ?? fallback; +} + /// Preserves the highlighted color used by legacy 24-bit Library badges while /// also supporting newer labels that display a measured bitrate instead. bool shouldHighlightAudioQualityBadge(String quality) { diff --git a/test/audio_quality_badge_policy_test.dart b/test/audio_quality_badge_policy_test.dart index 46ea2357..6a9aa0e5 100644 --- a/test/audio_quality_badge_policy_test.dart +++ b/test/audio_quality_badge_policy_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; void main() { @@ -22,4 +23,68 @@ void main() { expect(shouldHighlightAudioQualityBadge('16-bit/44.1kHz'), isFalse); }); }); + + group('Library audio quality label mode', () { + test('uses measured bitrate by default', () { + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitrate, + format: 'flac', + bitrateKbps: 1760, + bitDepth: 24, + sampleRate: 48000, + ), + 'FLAC 1760kbps', + ); + expect( + normalizeLibraryQualityLabelMode('unsupported'), + AppSettings.libraryQualityLabelBitrate, + ); + }); + + test('restores legacy bit depth and sample rate labels', () { + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitDepth, + format: 'flac', + bitrateKbps: 1760, + bitDepth: 24, + sampleRate: 48000, + ), + '24-bit/48kHz', + ); + }); + + test('keeps bitrate meaningful for lossy formats', () { + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitDepth, + format: 'mp3', + bitrateKbps: 320, + bitDepth: 16, + sampleRate: 44100, + ), + 'MP3 320kbps', + ); + }); + + test('falls back when the preferred metadata is unavailable', () { + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitDepth, + format: 'flac', + bitrateKbps: 950, + storedQuality: 'LOSSLESS', + ), + 'FLAC 950kbps', + ); + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitrate, + storedQuality: '24-bit/96kHz', + ), + '24-bit/96kHz', + ); + }); + }); } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index c9943cf8..9a644208 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -510,6 +510,10 @@ void main() { expect(settings.deduplicateDownloads, isTrue); expect(settings.allowQualityVariants, isFalse); expect(settings.nativeDownloadWorkerEnabled, isFalse); + expect( + settings.libraryQualityLabelMode, + AppSettings.libraryQualityLabelBitrate, + ); }); test('copyWith updates values and can clear nullable provider fields', () { @@ -526,6 +530,7 @@ void main() { lyricsAppleElrcWordSync: true, deduplicateDownloads: false, allowQualityVariants: true, + libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth, clearDownloadFallbackExtensionIds: true, clearSearchProvider: true, clearHomeFeedProvider: true, @@ -537,6 +542,10 @@ void main() { expect(updated.lyricsAppleElrcWordSync, isTrue); expect(updated.deduplicateDownloads, isFalse); expect(updated.allowQualityVariants, isTrue); + expect( + updated.libraryQualityLabelMode, + AppSettings.libraryQualityLabelBitDepth, + ); expect(updated.downloadFallbackExtensionIds, isNull); expect(updated.searchProvider, isNull); expect(updated.homeFeedProvider, isNull); @@ -563,6 +572,7 @@ void main() { deduplicateDownloads: false, allowQualityVariants: true, nativeDownloadWorkerEnabled: true, + libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth, ); final decoded = AppSettings.fromJson(settings.toJson()); @@ -582,6 +592,10 @@ void main() { expect(decoded.musixmatchLanguage, 'id'); expect(decoded.lyricsAppleElrcWordSync, isTrue); expect(decoded.lastSeenVersion, '4.5.0'); + expect( + decoded.libraryQualityLabelMode, + AppSettings.libraryQualityLabelBitDepth, + ); expect(decoded.deduplicateDownloads, isFalse); expect(decoded.allowQualityVariants, isTrue); expect(decoded.nativeDownloadWorkerEnabled, isTrue);