mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
fix(album): show Material quality badges only for exceptions
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user