diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 85cc7dbd..daed8471 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -267,17 +267,45 @@ class _AlbumScreenState extends ConsumerState return stripPrefixedResourceId(widget.albumId); } - List _audioTraitInline() { + static final _qualitySeparators = RegExp(r'[\s_-]+'); + + static (String, bool?) _qualitySignature(Track track) => ( + (track.audioQuality ?? '').trim().toLowerCase().replaceAll( + _qualitySeparators, + '', + ), + (track.audioModes?.trim().isEmpty ?? true) ? null : track.isDolbyAtmos, + ); + + Track? _commonQualityTrack(List tracks) { + final counts = <(String, bool?), int>{}; + for (final track in tracks) { + final signature = _qualitySignature(track); + if (signature.$1.isEmpty && signature.$2 != true) continue; + counts.update(signature, (count) => count + 1, ifAbsent: () => 1); + } + // A strict majority avoids choosing an arbitrary baseline for mixed albums. + // Unknown metadata counts towards the total, but never implies a quality. + for (final track in tracks) { + if ((counts[_qualitySignature(track)] ?? 0) > tracks.length / 2) { + return track; + } + } + return null; + } + + List _audioTraitInline({Track? commonQualityTrack}) { final traits = _audioTraits .map((t) => t.toLowerCase().trim()) .where((t) => t.isNotEmpty) .toSet(); - if (traits.isEmpty) return const []; - bool has(List keys) => keys.any(traits.contains); final items = []; - if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) { + if ((commonQualityTrack == null + ? null + : _qualitySignature(commonQualityTrack).$2) ?? + has(['atmos', 'dolby_atmos', 'dolby-atmos'])) { items.add( Builder( builder: (context) => DolbyAtmosLogo( @@ -296,10 +324,23 @@ class _AlbumScreenState extends ConsumerState items.add(HeaderMetaItem('Lossless', icon: Icons.graphic_eq)); } + final quality = commonQualityTrack?.audioQuality?.trim(); + if (quality != null && + quality.isNotEmpty && + !items.whereType().any( + (item) => item.label.toLowerCase() == quality.toLowerCase(), + )) { + items.add(HeaderMetaItem(quality)); + } + return items; } - Widget _buildHeaderMeta(BuildContext context, String? releaseDate) { + Widget _buildHeaderMeta( + BuildContext context, + String? releaseDate, + Track? commonQualityTrack, + ) { final items = []; final genre = _tracks?.firstOrNull?.genre?.trim(); @@ -308,7 +349,7 @@ class _AlbumScreenState extends ConsumerState } final year = _releaseYear(releaseDate); if (year != null) items.add(HeaderMetaItem(year)); - items.addAll(_audioTraitInline()); + items.addAll(_audioTraitInline(commonQualityTrack: commonQualityTrack)); return ConstrainedBox( constraints: const BoxConstraints(minHeight: 20), @@ -386,6 +427,9 @@ class _AlbumScreenState extends ConsumerState Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final tracks = _tracks ?? []; + final commonQualityTrack = context.isMornye + ? null + : _commonQualityTrack(tracks); final pageBackgroundColor = colorScheme.surface; final bottomPadding = isSelectionMode ? MediaQuery.paddingOf(context).bottom @@ -407,7 +451,12 @@ class _AlbumScreenState extends ConsumerState tracks, bottomPadding, ), - appBar: _buildAppBar(context, colorScheme, pageBackgroundColor), + appBar: _buildAppBar( + context, + colorScheme, + pageBackgroundColor, + commonQualityTrack, + ), slivers: [ if (_description != null) SliverToBoxAdapter( @@ -437,7 +486,7 @@ class _AlbumScreenState extends ConsumerState ), ), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ - _buildTrackList(context, colorScheme, tracks), + _buildTrackList(context, colorScheme, tracks, commonQualityTrack), _buildAlbumFooter(context, colorScheme, tracks), ], ], @@ -448,6 +497,7 @@ class _AlbumScreenState extends ConsumerState BuildContext context, ColorScheme colorScheme, Color pageBackgroundColor, + Track? commonQualityTrack, ) { final tracks = _tracks ?? []; final artistName = @@ -557,7 +607,7 @@ class _AlbumScreenState extends ConsumerState overflow: TextOverflow.ellipsis, ) : null, - meta: _buildHeaderMeta(context, releaseDate), + meta: _buildHeaderMeta(context, releaseDate, commonQualityTrack), actions: isSelectionMode ? null : context.isMornye @@ -632,6 +682,7 @@ class _AlbumScreenState extends ConsumerState BuildContext context, ColorScheme colorScheme, List tracks, + Track? commonQualityTrack, ) { _historySnapshot.update(tracks); final historyLookups = _historySnapshot.lookups; @@ -669,11 +720,13 @@ class _AlbumScreenState extends ConsumerState forceQualityPicker: forceQualityPicker, ), clickableArtist: true, - showQualityBadges: - !context.isMornye || - !_audioTraits.any( - (trait) => trait.toLowerCase().contains('lossless'), - ), + showQualityBadges: context.isMornye + ? !_audioTraits.any( + (trait) => trait.toLowerCase().contains('lossless'), + ) + : commonQualityTrack == null || + _qualitySignature(track) != + _qualitySignature(commonQualityTrack), isSelectionMode: isSelectionMode, isSelected: selectedIds.contains(selectionId), onToggleSelection: () => toggleSelection(selectionId), diff --git a/test/album_track_metadata_test.dart b/test/album_track_metadata_test.dart index 1c679c5d..4b05360d 100644 --- a/test/album_track_metadata_test.dart +++ b/test/album_track_metadata_test.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/album_description.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; @@ -188,6 +189,150 @@ void main() { } } + for (final scenario + in < + ({ + String name, + List<(String?, bool?)> qualities, + Set badgedRows, + String? headerQuality, + bool headerAtmos, + bool catalogAtmos, + }) + >[ + ( + name: 'uniform', + qualities: [('16-bit', true), ('16-bit', true), ('16-bit', true)], + badgedRows: {}, + headerQuality: '16-bit', + headerAtmos: true, + catalogAtmos: false, + ), + ( + name: 'bit depth exception first', + qualities: [('24-bit', true), ('16-bit', true), ('16 bit', true)], + badgedRows: {0}, + headerQuality: '16-bit', + headerAtmos: true, + catalogAtmos: false, + ), + ( + name: 'Atmos exception', + qualities: [('16-bit', false), ('16-bit', true), ('16-bit', false)], + badgedRows: {1}, + headerQuality: '16-bit', + headerAtmos: false, + catalogAtmos: false, + ), + ( + name: 'mixed without a majority', + qualities: [('16-bit', false), ('24-bit', true)], + badgedRows: {0, 1}, + headerQuality: null, + headerAtmos: false, + catalogAtmos: false, + ), + ( + name: 'mostly unknown', + qualities: [('16-bit', false), (null, false), (null, false)], + badgedRows: {0}, + headerQuality: null, + headerAtmos: false, + catalogAtmos: false, + ), + ( + name: 'album Atmos with unknown track modes', + qualities: [('16-bit', null), ('16-bit', null), ('16-bit', null)], + badgedRows: {}, + headerQuality: '16-bit', + headerAtmos: true, + catalogAtmos: true, + ), + ]) { + testWidgets('Material album quality badges: ${scenario.name}', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(430, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumScreen( + albumId: 'quality-${scenario.name}', + albumName: 'Example Album', + extensionId: 'example-metadata', + audioTraits: [ + 'lossless', + if (scenario.catalogAtmos) 'dolby_atmos', + ], + tracks: [ + for (var i = 0; i < scenario.qualities.length; i++) + Track( + id: 'song-$i', + name: 'Song $i', + artistName: 'Example Artist', + albumName: 'Example Album', + duration: 180000, + trackNumber: i + 1, + audioQuality: scenario.qualities[i].$1, + audioModes: switch (scenario.qualities[i].$2) { + true => 'DOLBY_ATMOS', + false => 'STEREO', + null => null, + }, + explicit: i == 0, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final rows = find.byType(TrackListTile); + expect(rows, findsNWidgets(scenario.qualities.length)); + for (var i = 0; i < scenario.qualities.length; i++) { + final badges = find.descendant( + of: rows.at(i), + matching: find.byWidgetPredicate( + (widget) => + widget is AudioQualityBadge || widget is DolbyAtmosBadge, + ), + ); + expect( + badges, + scenario.badgedRows.contains(i) ? findsWidgets : findsNothing, + reason: 'Song $i in ${scenario.name}', + ); + } + final header = find.byType(HeaderMetaRow); + if (scenario.headerQuality != null) { + expect( + find.descendant( + of: header, + matching: find.text(scenario.headerQuality!), + ), + findsOneWidget, + ); + } else { + expect( + find.descendant(of: header, matching: find.text('16-bit')), + findsNothing, + ); + } + expect( + find.descendant(of: header, matching: find.byType(DolbyAtmosLogo)), + scenario.headerAtmos ? findsOneWidget : findsNothing, + ); + expect(find.byType(ExplicitBadge), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }); + } + testWidgets('album tracks retain the extended tags supplied in search', ( tester, ) async {