@@ -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]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user