diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 617aa597..b323ea66 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -36,6 +36,7 @@ import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; +import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class _AlbumCache { @@ -628,6 +629,14 @@ class _AlbumScreenState extends ConsumerState final existingHistoryKeys = ref.watch( downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); + int discNumber(Track track) { + final number = track.discNumber ?? 1; + return number > 0 ? number : 1; + } + + final hasMultipleDiscs = tracks.any( + (track) => discNumber(track) > 1 || (track.totalDiscs ?? 1) > 1, + ); return SliverPadding( padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), sliver: SliverList( @@ -637,7 +646,7 @@ class _AlbumScreenState extends ConsumerState final isInHistory = existingHistoryKeys.contains( historyLookups[index].lookupKey, ); - return KeyedSubtree( + final row = KeyedSubtree( key: ValueKey(selectionId), child: StaggeredListItem( index: index, @@ -675,6 +684,18 @@ class _AlbumScreenState extends ConsumerState ), ), ); + final disc = discNumber(track); + if (hasMultipleDiscs && + (index == 0 || discNumber(tracks[index - 1]) != disc)) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DiscSeparatorChip(discNumber: disc), + row, + ], + ); + } + return row; }, childCount: tracks.length), ), ); diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index eb4d1d69..c14e5ba0 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -490,10 +490,12 @@ class _CollectionHeaderSkeleton extends StatelessWidget { children: [ const SkeletonBox(width: 48, height: 48, borderRadius: 24), const SizedBox(width: 16), - SkeletonBox( - width: screenWidth * 0.45, - height: 48, - borderRadius: 24, + Flexible( + child: SkeletonBox( + width: screenWidth * 0.45, + height: 48, + borderRadius: 24, + ), ), const SizedBox(width: 16), const SkeletonBox(width: 48, height: 48, borderRadius: 24), diff --git a/lib/widgets/disc_separator_chip.dart b/lib/widgets/disc_separator_chip.dart index 4bce7686..f39c7c0a 100644 --- a/lib/widgets/disc_separator_chip.dart +++ b/lib/widgets/disc_separator_chip.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; /// "Disc N" chip with a trailing hairline, shown between disc groups in /// album track lists. @@ -11,6 +12,28 @@ class DiscSeparatorChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider( + height: 1, + color: MornyeTheme.metadataDividerColor(context), + ), + const SizedBox(height: 12), + Text( + context.l10n.downloadedAlbumDiscHeader(discNumber), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), diff --git a/test/album_track_metadata_test.dart b/test/album_track_metadata_test.dart index eb9b6969..05a7c931 100644 --- a/test/album_track_metadata_test.dart +++ b/test/album_track_metadata_test.dart @@ -13,6 +13,7 @@ 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/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; void main() { @@ -109,6 +110,84 @@ void main() { } } + for (final mornye in [false, true]) { + for (final discs in [1, 2]) { + testWidgets('online album disc groups (Mornye: $mornye, discs: $discs)', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(430, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + backendMessenger.setMockMethodCallHandler(backendChannel, (call) async { + if (call.method != 'getProviderMetadata') return null; + return jsonEncode({ + 'album_info': {'name': 'Example Album', 'total_tracks': 2}, + 'track_list': [ + for (var index = 0; index < 2; index++) + { + 'id': 'song-$index', + 'name': 'Song $index', + 'artists': 'Example Artist', + 'disc_number': index == 0 ? 1 : discs, + 'total_discs': discs, + 'track_number': discs == 1 ? index + 1 : 1, + }, + ], + }); + }); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ExtensionAlbumScreen( + extensionId: 'example-metadata', + albumId: 'disc-album-$mornye-$discs', + albumName: 'Example Album', + ), + ), + ), + ); + await tester.pumpAndSettle(); + final tiles = tester.widgetList( + find.byType(TrackListTile), + ); + expect(tiles.map((tile) => tile.track.discNumber), [1, discs]); + expect(tiles.map((tile) => tile.track.totalDiscs), [discs, discs]); + expect( + tiles.map((tile) => tile.track.trackNumber), + discs == 1 ? [1, 2] : [1, 1], + ); + if (discs == 1) { + expect(find.byType(DiscSeparatorChip), findsNothing); + } else { + final headers = tester.widgetList( + find.byType(DiscSeparatorChip), + ); + expect(headers.map((header) => header.discNumber), [1, 2]); + expect( + tester.getTopLeft(find.text('Disc 2')).dy, + inExclusiveRange( + tester.getBottomLeft(find.text('Song 0')).dy, + tester.getTopLeft(find.text('Song 1')).dy, + ), + ); + await tester.longPress(find.text('Song 1')); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(TrackListTile)) + .map((tile) => tile.isSelected), + [false, true], + ); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }); + } + } + testWidgets('album tracks retain the extended tags supplied in search', ( tester, ) async {