fix(music): keep now playing state current

close #1600
This commit is contained in:
edde746
2026-07-17 16:30:10 +02:00
parent 5b90c4bca0
commit 70cd7e8c67
18 changed files with 741 additions and 67 deletions
+14
View File
@@ -15,6 +15,7 @@ import '../test_helpers/media_items.dart';
MediaItem _movie({
String id = 'm1',
String? title = 'Movie',
int? year,
int? viewCount,
int? leafCount,
int? viewedLeafCount,
@@ -29,6 +30,7 @@ MediaItem _movie({
backend: backend,
kind: MediaKind.movie,
title: title,
year: year,
viewCount: viewCount,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
@@ -446,4 +448,16 @@ void main() {
expect(movie.displayTitle, '');
});
});
group('MediaItem music metadata', () {
test('album year is exposed only for tracks and albums', () {
final track = testMediaItem(kind: MediaKind.track, parentTitle: 'Album', year: 2001);
final album = testMediaItem(kind: MediaKind.album, title: 'Album', year: 2001);
expect(track.albumTitle, 'Album');
expect(track.albumYear, 2001);
expect(album.albumYear, 2001);
expect(_movie(year: 2001).albumYear, isNull);
});
});
}
@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/music/now_playing_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
MediaItem _track({required String id, required String title, required String album, required int year}) {
return testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: title,
parentId: 'album_$id',
parentTitle: album,
grandparentId: 'artist_$id',
grandparentTitle: 'Artist $id',
year: year,
durationMs: const Duration(minutes: 3).inMilliseconds,
serverId: 'server_1',
);
}
class _FakeMusicService extends StubMusicPlaybackService {
MediaItem track;
final MusicPlayContext context;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
final List<Duration> seeks = [];
Duration _position = Duration.zero;
_FakeMusicService({required this.track, required this.context});
void advanceTo(MediaItem next) {
track = next;
_position = Duration.zero;
notifyListeners();
}
void emitPosition(Duration position) {
_position = position;
_positionController.add(position);
}
@override
bool get isAvailable => true;
@override
MediaItem get currentTrack => track;
@override
MusicPlaybackStatus get status => MusicPlaybackStatus.playing;
@override
Duration get position => _position;
@override
Stream<Duration> get positionStream => _positionController.stream;
@override
Duration get duration => const Duration(minutes: 3);
@override
List<MediaItem> get queue => [track];
@override
int get currentIndex => 0;
@override
MusicPlayContext get playContext => context;
@override
Future<void> seek(Duration position) async {
seeks.add(position);
_position = position;
}
@override
void dispose() {
_positionController.close();
super.dispose();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(null);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
});
Future<void> pumpNowPlaying(WidgetTester tester, _FakeMusicService service, {required bool isTv}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1200, 700);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
TvDetectionService.debugSetAppleTVOverride(isTv);
PlatformDetector.debugSetIsDesktopOSOverride(!isTv);
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(service.dispose);
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
await tester.pumpWidget(
InputModeTracker(
child: TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: service),
],
child: MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: isTv ? TargetPlatform.android : TargetPlatform.windows),
home: const NowPlayingScreen(),
),
),
),
),
);
await tester.pump();
}
for (final isTv in [false, true]) {
testWidgets('${isTv ? 'TV' : 'desktop'} follows the current album when an album queue crosses albums', (
tester,
) async {
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
final service = _FakeMusicService(
track: first,
context: const MusicPlayContext(id: 'album_one', title: 'First Album', kind: MusicPlayContextKind.album),
);
await pumpNowPlaying(tester, service, isTv: isTv);
expect(find.text(t.music.playingFrom(title: 'First Album · 1973')), findsOneWidget);
service.advanceTo(second);
await tester.pump();
expect(find.text(t.music.playingFrom(title: 'First Album · 1973')), findsNothing);
expect(find.text(t.music.playingFrom(title: 'Second Album · 1999')), findsOneWidget);
});
}
testWidgets('playlist playback retains its queue provenance label', (tester) async {
final service = _FakeMusicService(
track: _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973),
context: const MusicPlayContext(id: 'playlist_1', title: 'Road Trip', kind: MusicPlayContextKind.playlist),
);
await pumpNowPlaying(tester, service, isTv: false);
expect(find.text(t.music.playingFrom(title: 'Road Trip')), findsOneWidget);
expect(find.text(t.music.playingFrom(title: 'First Album · 1973')), findsNothing);
});
testWidgets('pending d-pad seek is discarded when the track changes', (tester) async {
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
final service = _FakeMusicService(
track: first,
context: const MusicPlayContext(title: 'Queue', kind: MusicPlayContextKind.tracks),
);
await pumpNowPlaying(tester, service, isTv: true);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
service.advanceTo(second);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(service.seeks, isEmpty);
});
testWidgets('seek progress resets immediately when the track changes', (tester) async {
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
final service = _FakeMusicService(
track: first,
context: const MusicPlayContext(title: 'Queue', kind: MusicPlayContextKind.tracks),
);
await pumpNowPlaying(tester, service, isTv: false);
service.emitPosition(const Duration(minutes: 2));
await tester.pump();
Slider seekSlider() => tester
.widgetList<Slider>(find.byType(Slider))
.singleWhere((slider) => slider.max == const Duration(minutes: 3).inMilliseconds);
expect(seekSlider().value, 120000);
service.advanceTo(second);
await tester.pump();
expect(seekSlider().value, 0);
});
}
@@ -90,6 +90,7 @@ void main() {
expect(item.trackNumber, 1);
expect(item.discNumber, 1);
expect(item.albumTitle, 'Live at Testhalle');
expect(item.albumYear, 2022);
expect(item.albumArtistTitle, 'The Synth Pops');
// Artists == [AlbumArtist] → no per-track performer override.
expect(item.originalTitle, isNull);
@@ -89,6 +89,8 @@ class FakePlayer implements Player {
final List<Media?> setNextCalls = [];
final List<Duration> seeks = [];
final List<double> volumes = [];
Completer<void>? playGate;
Completer<void>? pauseGate;
/// Arming these URIs throws, simulating a native setNext failure.
final Set<String> failingSetNextUris = {};
@@ -178,6 +180,7 @@ class FakePlayer implements Player {
@override
Future<void> play() async {
playCalls++;
await playGate?.future;
_state = _state.copyWith(playing: true, completed: false);
playingCtrl.add(true);
}
@@ -185,6 +188,7 @@ class FakePlayer implements Player {
@override
Future<void> pause() async {
pauseCalls++;
await pauseGate?.future;
_state = _state.copyWith(playing: false);
playingCtrl.add(false);
}
@@ -351,6 +355,7 @@ class RecordedReport {
class FakeMediaServerClient extends Fake implements MediaServerClient {
final List<RecordedReport> reports = [];
final List<String> markedWatched = [];
Completer<List<MediaItem>>? instantMixGate;
Iterable<RecordedReport> reportsFor(String state) => reports.where((r) => r.state == state);
@@ -363,6 +368,14 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
@override
bool get marksWatchedOnPlaybackStopped => false;
@override
void close() {}
@override
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100}) {
return instantMixGate?.future ?? Future.value(const []);
}
@override
Future<void> markWatched(MediaItem item) async {
markedWatched.add(item.id);
@@ -419,6 +432,7 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
final MediaServerClient? client;
final Set<String> failingIds = {};
final Map<String, int> resolveCounts = {};
final Map<String, Completer<void>> resolveGates = {};
/// Per-track URL overrides (e.g. content:// shapes for offline tracks).
final Map<String, String> urlOverrides = {};
@@ -426,6 +440,7 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
@override
Future<MusicSource> resolve(MediaItem track) async {
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
await resolveGates[track.id]?.future;
if (failingIds.contains(track.id)) {
throw StateError('resolve failed for ${track.id}');
}
@@ -510,13 +525,14 @@ class _GatedVolumeWriter {
}
class _Harness {
_Harness._(this.service, this.resolver, this.client, this.controls, this.players);
_Harness._(this.service, this.resolver, this.client, this.controls, this.players, this.serverManager);
final MusicPlaybackServiceImpl service;
final FakeMusicSourceResolver resolver;
final FakeMediaServerClient client;
final FakeMediaControlsManager controls;
final List<FakePlayer> players;
final MultiServerManager serverManager;
/// Seeded into every created FakePlayer — lets a test configure arm
/// failures before the first player exists.
@@ -529,9 +545,10 @@ class _Harness {
final resolver = FakeMusicSourceResolver(client: client);
final controls = FakeMediaControlsManager();
final players = <FakePlayer>[];
final serverManager = MultiServerManager()..debugRegisterClientForTesting(client);
late final _Harness harness;
final service = MusicPlaybackServiceImpl(
serverManager: MultiServerManager(),
serverManager: serverManager,
resolver: resolver,
audioPlayerFactory: () {
final player = FakePlayer();
@@ -545,7 +562,7 @@ class _Harness {
completedConfirmDelay: Duration.zero,
volumePersistenceWriter: volumePersistenceWriter,
);
harness = _Harness._(service, resolver, client, controls, players);
harness = _Harness._(service, resolver, client, controls, players, serverManager);
return harness;
}
@@ -579,6 +596,7 @@ void main() {
player.closeControllers();
}
h.controls.closeControllers();
h.serverManager.dispose();
});
test('volume updates notify only the dedicated volume listenable', () async {
@@ -687,6 +705,47 @@ void main() {
expect(h.controls.metadataTitles, ['Track t1']);
});
test('a superseded slow gapless resolve cannot overwrite the newly requested arm', () async {
final oldArmGate = Completer<void>();
h.resolver.resolveGates[t2.id] = oldArmGate;
await h.playTracks([t1, t2]);
expect(h.player.armed, isNull);
h.service.addNext([t3]);
oldArmGate.complete();
await pumpEventQueue();
expect(h.player.armed?.uri, _urlFor(t3));
});
test('end-of-track sleep invalidates a slow gapless resolve', () async {
final armGate = Completer<void>();
h.resolver.resolveGates[t2.id] = armGate;
await h.playTracks([t1, t2]);
h.service.setSleepTimer(null, endOfTrack: true);
armGate.complete();
await pumpEventQueue();
expect(h.service.sleepTimerEndOfTrack, isTrue);
expect(h.player.armed, isNull);
});
test('a slow instant mix cannot replace a newer explicit queue', () async {
final mixGate = Completer<List<MediaItem>>();
h.client.instantMixGate = mixGate;
final mix = h.service.playInstantMix(t1);
await h.playTracks([t3]);
mixGate.complete([t1, t2]);
await mix;
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.service.queue.map((track) => track.id), [t3.id]);
});
test('trackTransition advances the cursor, re-arms, and reports the previous track stopped at duration', () async {
await h.playTracks([t1, t2, t3]);
@@ -876,6 +935,35 @@ void main() {
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
test('a late play completion cannot revive a stopped session', () async {
await h.playTracks([t1]);
await h.service.pause();
final gate = Completer<void>();
h.player.playGate = gate;
final play = h.service.play();
await h.service.stop();
gate.complete();
await play;
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
});
test('a late pause completion cannot change a stopped session', () async {
await h.playTracks([t1]);
final gate = Completer<void>();
h.player.pauseGate = gate;
final pause = h.service.pause();
await h.service.stop();
gate.complete();
await pause;
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
});
test('interruption pauses and resumes when the system says shouldResume', () async {
await h.playTracks([t1, t2]);
+5
View File
@@ -243,6 +243,7 @@ void main() {
'parentIndex': 1,
'parentRatingKey': '510',
'parentTitle': 'Season 1',
'parentYear': 2008,
'parentThumb': '/library/metadata/510/thumb/1',
'grandparentRatingKey': '500',
'grandparentTitle': 'Breaking Bad',
@@ -258,6 +259,7 @@ void main() {
expect(item.parentIndex, 1);
expect(item.parentId, '510');
expect(item.parentTitle, 'Season 1');
expect(item.year, isNull);
expect(item.parentThumbPath, '/library/metadata/510/thumb/1');
expect(item.grandparentId, '500');
expect(item.grandparentTitle, 'Breaking Bad');
@@ -299,6 +301,7 @@ void main() {
'index': 8,
'parentRatingKey': '700',
'parentTitle': 'Random Access Memories',
'parentYear': 2013,
'grandparentRatingKey': '699',
'grandparentTitle': 'Daft Punk',
'duration': 369000,
@@ -311,6 +314,8 @@ void main() {
expect(item.durationMs, 369000);
expect(item.parentId, '700');
expect(item.parentTitle, 'Random Access Memories');
expect(item.year, 2013);
expect(item.albumYear, 2013);
expect(item.grandparentId, '699');
expect(item.grandparentTitle, 'Daft Punk');
});
+89 -2
View File
@@ -1,4 +1,5 @@
import '../test_helpers/paged_fakes.dart';
import 'dart:async';
import 'dart:convert';
import 'package:drift/native.dart';
@@ -200,6 +201,30 @@ void main() {
expect(music.callCount, 2);
expect(music.playedTracks, tracks);
expect(music.shuffle, isTrue);
final staleFetchGate = Completer<void>();
client.fetchGate = staleFetchGate;
menuKey.currentState!.showContextMenu(tester.element(find.text('audio target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.play));
await tester.pump();
final newerTrack = testMediaItem(
id: 'newer-track',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Newer Track',
serverId: 'srv-1',
);
await music.playFromList(
tracks: [newerTrack],
playContext: const MusicPlayContext(title: 'Newer Queue', kind: MusicPlayContextKind.tracks),
);
staleFetchGate.complete();
await tester.pumpAndSettle();
expect(music.callCount, 3, reason: 'the stale playlist fetch must not start a fourth queue');
expect(music.playedTracks, [newerTrack]);
expect(tester.takeException(), isNull);
});
@@ -408,6 +433,49 @@ void main() {
same(harness.music),
);
});
testWidgets('a slow music enqueue does not append to a newer queue session', (tester) async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
serverId: 'srv-1',
);
final albumTrack = testMediaItem(
id: 'album-track',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Album Track',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: [albumTrack]);
final fetchGate = Completer<void>();
harness.client.albumTracksGate = fetchGate;
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.music.playNext));
await tester.pump();
final newerTrack = testMediaItem(
id: 'newer-track',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Newer Track',
serverId: 'srv-1',
);
await harness.music.playFromList(
tracks: [newerTrack],
playContext: const MusicPlayContext(title: 'Newer Queue', kind: MusicPlayContextKind.tracks),
);
fetchGate.complete();
await tester.pumpAndSettle();
expect(harness.music.addedNext, isEmpty);
expect(harness.music.playedTracks, [newerTrack]);
expect(tester.takeException(), isNull);
});
});
}
@@ -522,6 +590,7 @@ Future<void> _openPlaylistPicker(WidgetTester tester, GlobalKey<MediaContextMenu
class _AudioPlaylistClient implements MediaServerClient {
final List<MediaItem> tracks;
Completer<void>? fetchGate;
_AudioPlaylistClient(this.tracks);
@@ -539,6 +608,7 @@ class _AudioPlaylistClient implements MediaServerClient {
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
await fetchGate?.future;
return fakeLibraryPage(tracks, start: start, size: size);
}
@@ -551,6 +621,7 @@ class _AudioPlaylistClient implements MediaServerClient {
class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
List<MediaItem>? playedTracks;
final List<MediaItem> addedNext = [];
MusicPlayContext? playedContext;
bool? shuffle;
int callCount = 0;
@@ -565,17 +636,27 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
required MusicPlayContext playContext,
bool shuffle = false,
}) async {
await super.playFromList(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
callCount++;
playedTracks = tracks;
playedContext = playContext;
this.shuffle = shuffle;
}
@override
void addNext(List<MediaItem> tracks) {
addedNext.addAll(tracks);
}
}
class _RelatedMusicClient implements MediaServerClient {
_RelatedMusicClient(Iterable<MediaItem> items) : _items = {for (final item in items) item.id: item};
_RelatedMusicClient(Iterable<MediaItem> items)
: _items = {for (final item in items) item.id: item},
albumTracks = items.where((item) => item.kind == MediaKind.track).toList();
final Map<String, MediaItem> _items;
final List<MediaItem> albumTracks;
Completer<void>? albumTracksGate;
@override
ServerId get serverId => ServerId('srv-1');
@@ -593,7 +674,10 @@ class _RelatedMusicClient implements MediaServerClient {
Future<MediaItem?> fetchItem(String id) async => _items[id];
@override
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async => const [];
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async {
await albumTracksGate?.future;
return albumTracks;
}
@override
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist) async => const [];
@@ -611,12 +695,14 @@ class _SiblingMusicMenuHarness {
required this.profileNavigatorKey,
required this.menuKey,
required this.music,
required this.client,
});
final GlobalKey<NavigatorState> rootNavigatorKey;
final GlobalKey<NavigatorState> profileNavigatorKey;
final GlobalKey<MediaContextMenuState> menuKey;
final _RecordingMusicPlaybackService music;
final _RelatedMusicClient client;
}
Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
@@ -722,6 +808,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
profileNavigatorKey: profileNavigatorKey,
menuKey: menuKey,
music: music,
client: client,
);
}
+58
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -69,6 +71,8 @@ class _FakeDownloadProvider extends ChangeNotifier implements DownloadProvider {
/// Fixed-state fake: reports a playing session with a single-track queue.
class _FakeMusicService extends StubMusicPlaybackService {
MediaItem? track;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
Duration _position = Duration.zero;
int previousCalls = 0;
int toggleCalls = 0;
int nextCalls = 0;
@@ -85,6 +89,12 @@ class _FakeMusicService extends StubMusicPlaybackService {
@override
MusicPlaybackStatus get status => track == null ? MusicPlaybackStatus.idle : MusicPlaybackStatus.playing;
@override
Duration get position => _position;
@override
Stream<Duration> get positionStream => _positionController.stream;
@override
Duration? get duration => track == null ? null : const Duration(minutes: 3);
@@ -94,6 +104,17 @@ class _FakeMusicService extends StubMusicPlaybackService {
@override
int get currentIndex => track == null ? -1 : 0;
void emitPosition(Duration position) {
_position = position;
_positionController.add(position);
}
void advanceTo(MediaItem next) {
track = next;
_position = Duration.zero;
notifyListeners();
}
@override
Future<void> previous() async {
previousCalls++;
@@ -113,6 +134,12 @@ class _FakeMusicService extends StubMusicPlaybackService {
Future<void> stop() async {
stopCalls++;
}
@override
void dispose() {
_positionController.close();
super.dispose();
}
}
void main() {
@@ -183,6 +210,37 @@ void main() {
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
});
testWidgets('progress resets immediately when the current track changes', (tester) async {
final nextTrack = testMediaItem(
id: 'track_2',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Noon',
durationMs: 180000,
serverId: 'server_1',
);
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
service.emitPosition(const Duration(minutes: 2));
await tester.pump();
Iterable<double?> progressWidths() => tester
.widgetList<FractionallySizedBox>(find.byType(FractionallySizedBox))
.where((box) => box.heightFactor == 1)
.map((box) => box.widthFactor);
expect(progressWidths(), contains(closeTo(2 / 3, 0.001)));
service.advanceTo(nextTrack);
await tester.pump();
expect(progressWidths(), contains(0.0));
expect(progressWidths(), isNot(contains(closeTo(2 / 3, 0.001))));
});
testWidgets('tapping the card edge opens the named Now Playing route', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();