fix(album): show Material quality badges only for exceptions

This commit is contained in:
zarzet
2026-09-27 16:15:17 +07:00
parent 579c6fe4d9
commit a9ed5a19cd
2 changed files with 212 additions and 14 deletions
+67 -14
View File
@@ -267,17 +267,45 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
return stripPrefixedResourceId(widget.albumId);
}
List<Widget> _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<Track> 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<Widget> _audioTraitInline({Track? commonQualityTrack}) {
final traits = _audioTraits
.map((t) => t.toLowerCase().trim())
.where((t) => t.isNotEmpty)
.toSet();
if (traits.isEmpty) return const [];
bool has(List<String> keys) => keys.any(traits.contains);
final items = <Widget>[];
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<AlbumScreen>
items.add(HeaderMetaItem('Lossless', icon: Icons.graphic_eq));
}
final quality = commonQualityTrack?.audioQuality?.trim();
if (quality != null &&
quality.isNotEmpty &&
!items.whereType<HeaderMetaItem>().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 = <Widget>[];
final genre = _tracks?.firstOrNull?.genre?.trim();
@@ -308,7 +349,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
}
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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
),
),
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<AlbumScreen>
BuildContext context,
ColorScheme colorScheme,
Color pageBackgroundColor,
Track? commonQualityTrack,
) {
final tracks = _tracks ?? [];
final artistName =
@@ -557,7 +607,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
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<AlbumScreen>
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
Track? commonQualityTrack,
) {
_historySnapshot.update(tracks);
final historyLookups = _historySnapshot.lookups;
@@ -669,11 +720,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
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),
+145
View File
@@ -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<int> 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 {