diff --git a/lib/models/unified_library_item.dart b/lib/models/unified_library_item.dart index 4eccd54f..14a3d73f 100644 --- a/lib/models/unified_library_item.dart +++ b/lib/models/unified_library_item.dart @@ -110,6 +110,8 @@ class UnifiedLibraryItem { return buildLibraryAudioQualityLabel( mode: mode, format: history.format, + filePath: history.filePath, + fileName: history.safFileName, bitrateKbps: history.bitrate, bitDepth: history.bitDepth, sampleRate: history.sampleRate, @@ -122,6 +124,7 @@ class UnifiedLibraryItem { return buildLibraryAudioQualityLabel( mode: mode, format: local.format, + filePath: local.filePath, bitrateKbps: local.bitrate, bitDepth: local.bitDepth, sampleRate: local.sampleRate, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 86f2abbf..9ed61c07 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -495,6 +495,8 @@ class _DownloadedAlbumScreenState extends ConsumerState String? label(DownloadHistoryItem track) => buildLibraryAudioQualityLabel( mode: mode, format: track.format, + filePath: track.filePath, + fileName: track.safFileName, bitrateKbps: track.bitrate, bitDepth: track.bitDepth, sampleRate: track.sampleRate, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 39026267..7b3245c0 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -323,6 +323,7 @@ class _LocalAlbumScreenState extends ConsumerState String? label(LocalLibraryItem track) => buildLibraryAudioQualityLabel( mode: mode, format: track.format, + filePath: track.filePath, bitrateKbps: track.bitrate, bitDepth: track.bitDepth, sampleRate: track.sampleRate, diff --git a/lib/utils/audio_quality_badge_policy.dart b/lib/utils/audio_quality_badge_policy.dart index 43e26c05..1c8a59ba 100644 --- a/lib/utils/audio_quality_badge_policy.dart +++ b/lib/utils/audio_quality_badge_policy.dart @@ -49,11 +49,16 @@ String? _libraryFileFormatLabel(String? format) { String? buildLibraryAudioQualityLabel({ required String mode, String? format, + String? filePath, + String? fileName, int? bitrateKbps, int? bitDepth, int? sampleRate, String? storedQuality, }) { + final effectiveFormat = + normalizeOptionalString(format) ?? + audioFormatForPath(filePath, fileName: fileName); final stored = normalizeOptionalString(storedQuality); final storedBitrate = _bitrateFromStoredQuality(stored); final storedBitDepth = _bitDepthFromStoredQuality(stored); @@ -64,7 +69,10 @@ String? buildLibraryAudioQualityLabel({ ? bitDepth : storedBitDepth; final bitrateLabel = effectiveBitrate != null - ? buildDisplayAudioQuality(bitrateKbps: effectiveBitrate, format: format) + ? buildDisplayAudioQuality( + bitrateKbps: effectiveBitrate, + format: effectiveFormat, + ) : null; final bitDepthLabel = bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0 @@ -76,11 +84,14 @@ String? buildLibraryAudioQualityLabel({ final normalizedMode = normalizeLibraryQualityLabelMode(mode); if (normalizedMode == AppSettings.libraryQualityLabelFileFormat) { - return _libraryFileFormatLabel(format); + return _libraryFileFormatLabel(effectiveFormat); } - if (isLossyAudioFormat(format)) { - return bitrateLabel; + if (isLossyAudioFormat(effectiveFormat)) { + // Older rows may know the codec before their bitrate has been backfilled. + // Keep a useful badge without showing stale lossless specs or inventing a + // bitrate from the user's current download/conversion setting. + return bitrateLabel ?? _libraryFileFormatLabel(effectiveFormat); } return switch (normalizedMode) { diff --git a/test/audio_quality_badge_policy_test.dart b/test/audio_quality_badge_policy_test.dart index cde06fb6..34751891 100644 --- a/test/audio_quality_badge_policy_test.dart +++ b/test/audio_quality_badge_policy_test.dart @@ -1,8 +1,50 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/unified_library_item.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'; void main() { + test( + 'Library history and local cards retain labels for legacy Opus rows', + () { + final history = UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem( + id: 'history-song', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://music/document/42', + safFileName: 'Song.opus', + quality: '16-bit/44.1kHz', + service: 'example', + downloadedAt: DateTime(2026), + ), + ); + final local = UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem( + id: 'local-song', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/Song.opus', + scannedAt: DateTime(2026), + ), + ); + for (final item in [history, local]) { + expect( + item.qualityForMode(AppSettings.libraryQualityLabelBitDepth), + 'OPUS', + ); + expect( + item.qualityForMode(AppSettings.libraryQualityLabelBitrate), + 'OPUS', + ); + } + }, + ); + group('Library audio quality badge color', () { test('keeps legacy 24-bit labels highlighted', () { expect(shouldHighlightAudioQualityBadge('24-bit/96kHz'), isTrue); @@ -25,6 +67,77 @@ void main() { }); group('Library audio quality label mode', () { + test('keeps an Opus badge when bitrate has not been backfilled', () { + for (final mode in [ + AppSettings.libraryQualityLabelBitrate, + AppSettings.libraryQualityLabelBitDepth, + AppSettings.libraryQualityLabelBitDepthOnly, + AppSettings.libraryQualityLabelBitDepthBitrate, + AppSettings.libraryQualityLabelFileFormat, + ]) { + expect( + buildLibraryAudioQualityLabel( + mode: mode, + format: 'opus', + bitDepth: 16, + sampleRate: 44100, + storedQuality: '16-bit/44.1kHz', + ), + 'OPUS', + reason: mode, + ); + } + }); + + test('uses stored Opus bitrate until measured bitrate is available', () { + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitDepth, + format: 'opus', + storedQuality: 'OPUS 320kbps', + ), + 'OPUS 320kbps', + ); + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitDepth, + format: 'opus', + bitrateKbps: 256, + storedQuality: 'OPUS 320kbps', + ), + 'OPUS 256kbps', + ); + expect(formatLibraryGridAudioQualityLabel('OPUS 320kbps'), '320k'); + expect(formatLibraryGridAudioQualityLabel('OPUS'), 'OPUS'); + }); + + test('uses the path or SAF name when a legacy row has no format', () { + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitDepth, + filePath: '/music/Song.opus', + storedQuality: '16-bit/44.1kHz', + ), + 'OPUS', + ); + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelBitrate, + filePath: 'content://music/document/42', + fileName: 'Song.OPUS', + ), + 'OPUS', + ); + expect( + buildLibraryAudioQualityLabel( + mode: AppSettings.libraryQualityLabelFileFormat, + format: 'alac', + filePath: '/music/Song.m4a', + ), + 'ALAC', + ); + }); + test('uses measured bitrate by default', () { expect( buildLibraryAudioQualityLabel(