feat(player): show covers and expressive controls in Material queue

This commit is contained in:
zarzet
2026-09-28 11:58:50 +07:00
parent 892911c825
commit aeba9b879e
2 changed files with 117 additions and 27 deletions
+54 -27
View File
@@ -35,6 +35,7 @@ import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart';
@@ -1784,7 +1785,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
Widget _lyricsSection(ColorScheme colorScheme, {required bool isActive}) {
if (_loadingMeta) {
return const Center(child: CircularProgressIndicator());
return const Center(child: AppLoadingIndicator());
}
if (_lyrics.isEmpty) {
return Center(
@@ -2396,26 +2397,33 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
padding: const EdgeInsets.fromLTRB(24, 4, 16, 8),
child: Row(
children: [
Text(
context.l10n.nowPlayingUpNext,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
Expanded(
child: Text(
context.l10n.nowPlayingUpNext,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
const Spacer(),
IconButton(
ExpressiveIconButton(
tooltip: autoplay
? context.l10n.autoplayOn
: context.l10n.autoplayOff,
isSelected: autoplay,
selected: autoplay,
icon: const Icon(Icons.all_inclusive),
color: autoplay ? colorScheme.primary : null,
foregroundColor: autoplay
? colorScheme.onSecondaryContainer
: colorScheme.onSurfaceVariant,
backgroundColor: autoplay
? colorScheme.secondaryContainer
: null,
onPressed: () => ref
.read(settingsProvider.notifier)
.setAutoplay(!autoplay),
),
IconButton(
const SizedBox(width: 4),
ExpressiveIconButton(
tooltip: switch (repeatMode) {
AudioServiceRepeatMode.one =>
context.l10n.nowPlayingRepeatOne,
@@ -2423,15 +2431,19 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
context.l10n.nowPlayingRepeatOff,
_ => context.l10n.nowPlayingRepeatAll,
},
isSelected:
repeatMode != AudioServiceRepeatMode.none,
selected: repeatMode != AudioServiceRepeatMode.none,
icon: Icon(
repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
),
color: repeatMode != AudioServiceRepeatMode.none
? colorScheme.primary
foregroundColor:
repeatMode != AudioServiceRepeatMode.none
? colorScheme.onSecondaryContainer
: colorScheme.onSurfaceVariant,
backgroundColor:
repeatMode != AudioServiceRepeatMode.none
? colorScheme.secondaryContainer
: null,
onPressed: () =>
controller.setRepeatMode(switch (repeatMode) {
@@ -2442,13 +2454,19 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
_ => AudioServiceRepeatMode.none,
}),
),
IconButton(
const SizedBox(width: 4),
ExpressiveIconButton(
tooltip: shuffleOn
? context.l10n.nowPlayingShuffleOn
: context.l10n.nowPlayingPlayInOrder,
isSelected: shuffleOn,
selected: shuffleOn,
icon: const Icon(Icons.shuffle),
color: shuffleOn ? colorScheme.primary : null,
foregroundColor: shuffleOn
? colorScheme.onSecondaryContainer
: colorScheme.onSurfaceVariant,
backgroundColor: shuffleOn
? colorScheme.secondaryContainer
: null,
onPressed: () => controller.setShuffle(!shuffleOn),
),
],
@@ -2458,10 +2476,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
child: ExpressiveButton(
tonal: true,
onPressed: () => _shuffleLibrary(controller),
icon: const Icon(Icons.shuffle, size: 18),
label: Text(context.l10n.nowPlayingShuffleLibrary),
child: Text(context.l10n.nowPlayingShuffleLibrary),
),
),
),
@@ -2501,17 +2520,25 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
enabled: !isCurrent,
onRemove: () => controller.removeQueuedItem(item),
child: ListTile(
selected: isCurrent,
selectedTileColor: colorScheme
.secondaryContainer
.withValues(alpha: 0.55),
contentPadding: const EdgeInsets.only(
left: 16,
right: 4,
),
leading: Icon(
isCurrent
? Icons.equalizer
: Icons.music_note,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurfaceVariant,
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox.square(
dimension: 44,
child: PlayerArtwork(
artUri: item.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth: 132,
iconSize: 22,
),
),
),
title: Text(
item.title,
@@ -40,6 +40,7 @@ import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
import 'package:spotiflac_android/widgets/mini_player.dart';
import 'package:spotiflac_android/widgets/playback_seek_slider.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -1848,6 +1849,68 @@ void main() {
);
}
for (final width in [320.0, 430.0]) {
testWidgets('Material queue shows covers and selected controls at $width', (
tester,
) async {
final queue = [item('first'), item('second')];
final controller = _QueueController(
(index) => mediaItems.add(queue[index]),
);
await pumpNowPlaying(
tester,
theme: AppTheme.dark(),
size: Size(width, 852),
queue: queue,
controller: controller,
playback: PlaybackState(
queueIndex: 0,
shuffleMode: AudioServiceShuffleMode.all,
repeatMode: AudioServiceRepeatMode.all,
),
);
mediaItems.add(queue.first);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.queue_music));
await tester.pumpAndSettle();
final rows = find.descendant(
of: find.byType(ReorderableListView),
matching: find.byType(ListTile),
);
expect(rows, findsNWidgets(2));
expect(
find.descendant(of: rows, matching: find.byType(PlayerArtwork)),
findsNWidgets(2),
);
expect(tester.widgetList<ListTile>(rows).map((row) => row.selected), [
true,
false,
]);
final toggles = tester.widgetList<ExpressiveIconButton>(
find.descendant(
of: find.byType(DraggableScrollableSheet),
matching: find.byType(ExpressiveIconButton),
),
);
expect(
toggles.where((button) => button.selected == true).length,
greaterThanOrEqualTo(2),
);
expect(
toggles
.where((button) => button.selected == true)
.every((button) => button.backgroundColor != null),
isTrue,
);
await tester.tap(
find.descendant(of: rows, matching: find.text('Second')),
);
await tester.pumpAndSettle();
expect(controller.selected, [1]);
expect(tester.takeException(), isNull);
});
}
testWidgets('Mornye player renders Apple-style transport controls', (
tester,
) async {