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
+4
View File
@@ -526,6 +526,10 @@ sealed class MediaItem with _$MediaItem {
_ => null, _ => null,
}; };
/// Release year for music items. Track mappers normalize the containing
/// album's year into [year] when the backend exposes it as parent metadata.
int? get albumYear => kind == MediaKind.track || kind == MediaKind.album ? year : null;
/// Album-artist name for music items: a track's grandparent, an album's /// Album-artist name for music items: a track's grandparent, an album's
/// parent. /// parent.
String? get albumArtistTitle => switch (kind) { String? get albumArtistTitle => switch (kind) {
+5 -2
View File
@@ -82,15 +82,18 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
/// gated on playback availability first so the stub never fetches. /// gated on playback availability first so the stub never fetches.
Future<void> _playAll({bool shuffle = false}) async { Future<void> _playAll({bool shuffle = false}) async {
if (!ensureMusicPlaybackAvailable(context)) return; if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
final intent = service.beginPlayIntent();
List<MediaItem> tracks; List<MediaItem> tracks;
try { try {
tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id); tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id);
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle); final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle);
if (mounted) showErrorSnackBar(context, message); showErrorSnackBar(context, message);
return; return;
} }
if (!mounted) return; if (!mounted || !service.isPlayIntentCurrent(intent)) return;
if (tracks.isEmpty) { if (tracks.isEmpty) {
showAppSnackBar(context, emptyMessage); showAppSnackBar(context, emptyMessage);
return; return;
+56 -18
View File
@@ -81,10 +81,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
/// ValueListenableBuilder-wrapped Transform so drag frames never rebuild /// ValueListenableBuilder-wrapped Transform so drag frames never rebuild
/// the screen. /// the screen.
final ValueNotifier<double> _dismissDrag = ValueNotifier<double>(0); final ValueNotifier<double> _dismissDrag = ValueNotifier<double>(0);
late final AnimationController _dismissSettle = AnimationController( late final AnimationController _dismissSettle;
vsync: this,
duration: const Duration(milliseconds: 200),
)..addListener(_onDismissSettleTick);
double _dismissSettleFrom = 0; double _dismissSettleFrom = 0;
final FocusNode _seekFocusNode = FocusNode(debugLabel: 'now_playing_seek'); final FocusNode _seekFocusNode = FocusNode(debugLabel: 'now_playing_seek');
@@ -95,6 +92,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
bool _poppedForIdle = false; bool _poppedForIdle = false;
@override
void initState() {
super.initState();
_dismissSettle = AnimationController(vsync: this, duration: const Duration(milliseconds: 200))
..addListener(_onDismissSettleTick);
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -306,7 +310,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) { Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
Widget upper = Column( Widget upper = Column(
children: [ children: [
_buildTopBar(track, service.playContext?.title), _buildTopBar(track, service.playContext),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -332,7 +336,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
return Column( return Column(
children: [ children: [
Expanded(child: upper), Expanded(child: upper),
Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _buildSeekBar()), Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _buildSeekBar(track)),
_buildTransportRow(service), _buildTransportRow(service),
_buildUtilityRow(showQueueButton: true), _buildUtilityRow(showQueueButton: true),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -344,7 +348,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
final tk = tokens(context); final tk = tokens(context);
return Column( return Column(
children: [ children: [
_buildTopBar(track, service.playContext?.title), _buildTopBar(track, service.playContext),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20), padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
@@ -360,7 +364,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
children: [ children: [
_buildTrackInfo(track, centered: false), _buildTrackInfo(track, centered: false),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSeekBar(), _buildSeekBar(track),
_buildWideControlBand(service), _buildWideControlBand(service),
const SizedBox(height: 12), const SizedBox(height: 12),
// Inline queue panel — same widget the queue sheet uses. // Inline queue panel — same widget the queue sheet uses.
@@ -386,7 +390,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
Widget _buildTvLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) { Widget _buildTvLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
final tk = tokens(context); final tk = tokens(context);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final playContextTitle = service.playContext?.title; final sourceTitle = _playingFromTitle(track, service.playContext);
final artist = track.trackArtistTitle; final artist = track.trackArtistTitle;
return Padding( return Padding(
@@ -404,10 +408,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
Row( Row(
children: [ children: [
Expanded( Expanded(
child: playContextTitle == null || playContextTitle.isEmpty child: sourceTitle == null
? const SizedBox.shrink() ? const SizedBox.shrink()
: Text( : Text(
t.music.playingFrom(title: playContextTitle), t.music.playingFrom(title: sourceTitle),
maxLines: 1, maxLines: 1,
overflow: .ellipsis, overflow: .ellipsis,
style: TextStyle(fontSize: 14, color: tk.textMuted), style: TextStyle(fontSize: 14, color: tk.textMuted),
@@ -433,7 +437,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
), ),
], ],
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSeekBar(), _buildSeekBar(track),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildTransportRow(service), _buildTransportRow(service),
_buildUtilityRow(showQueueButton: true), _buildUtilityRow(showQueueButton: true),
@@ -449,11 +453,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
// Pieces // Pieces
// ------------------------------------------------------------------- // -------------------------------------------------------------------
/// [playContextTitle] is passed in (not selected) because this builds /// [playContext] is passed in (not selected) because this builds
/// inside the layout-phase LayoutBuilder, where `context.select` on the /// inside the layout-phase LayoutBuilder, where `context.select` on the
/// screen's element asserts; the screen already watches the service. /// screen's element asserts; the screen already watches the service.
Widget _buildTopBar(MediaItem track, String? playContextTitle) { Widget _buildTopBar(MediaItem track, MusicPlayContext? playContext) {
final tk = tokens(context); final tk = tokens(context);
final sourceTitle = _playingFromTitle(track, playContext);
// macOS pins the traffic lights at y=21 (16pt buttons → center 29, see // macOS pins the traffic lights at y=21 (16pt buttons → center 29, see
// WindowUtilsPlugin.customButtonPositions); a fixed 58px row centers the // WindowUtilsPlugin.customButtonPositions); a fixed 58px row centers the
// close button on that line regardless of the platform visual density // close button on that line regardless of the platform visual density
@@ -479,10 +484,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
context: context, context: context,
)!, )!,
Expanded( Expanded(
child: playContextTitle == null || playContextTitle.isEmpty child: sourceTitle == null
? const SizedBox.shrink() ? const SizedBox.shrink()
: Text( : Text(
t.music.playingFrom(title: playContextTitle), t.music.playingFrom(title: sourceTitle),
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
overflow: .ellipsis, overflow: .ellipsis,
@@ -496,6 +501,19 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
); );
} }
/// Album and ad-hoc queues follow the active track's album as the queue
/// crosses album boundaries. Artist/playlist/mix contexts remain stable
/// provenance labels for the session they created.
String? _playingFromTitle(MediaItem track, MusicPlayContext? playContext) {
final followsCurrentAlbum =
playContext == null ||
playContext.kind == MusicPlayContextKind.album ||
playContext.kind == MusicPlayContextKind.tracks;
final title = (followsCurrentAlbum ? track.albumTitle : playContext.title)?.trim();
if (title == null || title.isEmpty) return null;
return toBulletedString([title, if (followsCurrentAlbum && track.albumYear != null) '${track.albumYear}']);
}
/// Wide-layout control band: the transport row stays exactly centered /// Wide-layout control band: the transport row stays exactly centered
/// (equal-width flanks), with lyrics + a desktop volume slider as a /// (equal-width flanks), with lyrics + a desktop volume slider as a
/// right-aligned cluster that scales down instead of overflowing when the /// right-aligned cluster that scales down instead of overflowing when the
@@ -670,8 +688,9 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
); );
} }
Widget _buildSeekBar() { Widget _buildSeekBar(MediaItem track) {
return _NowPlayingSeekBar( return _NowPlayingSeekBar(
trackKey: track.globalKey,
focusNode: _seekFocusNode, focusNode: _seekFocusNode,
onNavigateUp: PlatformDetector.isTV() ? _overflowFocusNode.requestFocus : null, onNavigateUp: PlatformDetector.isTV() ? _overflowFocusNode.requestFocus : null,
onNavigateDown: _focusTransport, onNavigateDown: _focusTransport,
@@ -980,12 +999,14 @@ class _PlayPauseButton extends StatelessWidget {
/// timeline's stepped key-repeat acceleration; focus renders as a /// timeline's stepped key-repeat acceleration; focus renders as a
/// text-based background pill behind the bar. /// text-based background pill behind the bar.
class _NowPlayingSeekBar extends StatefulWidget { class _NowPlayingSeekBar extends StatefulWidget {
final String trackKey;
final FocusNode focusNode; final FocusNode focusNode;
final VoidCallback? onNavigateUp; final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown; final VoidCallback? onNavigateDown;
final VoidCallback onBack; final VoidCallback onBack;
const _NowPlayingSeekBar({ const _NowPlayingSeekBar({
required this.trackKey,
required this.focusNode, required this.focusNode,
required this.onNavigateUp, required this.onNavigateUp,
required this.onNavigateDown, required this.onNavigateDown,
@@ -1012,13 +1033,27 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
_keySeek = DebouncedSeekAccumulator( _keySeek = DebouncedSeekAccumulator(
currentPosition: () => context.read<MusicPlaybackService>().position, currentPosition: () => context.read<MusicPlaybackService>().position,
duration: () => context.read<MusicPlaybackService>().duration ?? Duration.zero, duration: () => context.read<MusicPlaybackService>().duration ?? Duration.zero,
seek: (target) => unawaited(context.read<MusicPlaybackService>().seek(target)), seek: (target) {
final service = context.read<MusicPlaybackService>();
if (service.currentTrack?.globalKey == widget.trackKey) {
unawaited(service.seek(target));
}
},
onChanged: () { onChanged: () {
if (mounted) setState(() {}); if (mounted) setState(() {});
}, },
); );
} }
@override
void didUpdateWidget(covariant _NowPlayingSeekBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.trackKey == widget.trackKey) return;
_keySeek.cancel();
_dragValueMs = null;
_resetSeekState();
}
@override @override
void dispose() { void dispose() {
_keySeek.dispose(); _keySeek.dispose();
@@ -1082,6 +1117,7 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
final showFocus = _focused && InputModeTracker.isKeyboardMode(context); final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
final bar = StreamBuilder<Duration>( final bar = StreamBuilder<Duration>(
key: ValueKey(widget.trackKey),
stream: service.positionStream, stream: service.positionStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final duration = service.duration ?? Duration.zero; final duration = service.duration ?? Duration.zero;
@@ -1120,7 +1156,9 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null, onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
onChangeEnd: hasDuration onChangeEnd: hasDuration
? (value) { ? (value) {
if (service.currentTrack?.globalKey == widget.trackKey) {
unawaited(service.seek(Duration(milliseconds: value.round()))); unawaited(service.seek(Duration(milliseconds: value.round())));
}
setState(() => _dragValueMs = null); setState(() => _dragValueMs = null);
} }
: null, : null,
@@ -96,6 +96,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
return; return;
} }
if (!ensureMusicPlaybackAvailable(context)) return; if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
final intent = service.beginPlayIntent();
List<MediaItem> tracks; List<MediaItem> tracks;
if (_isPlaylistFullyLoaded) { if (_isPlaylistFullyLoaded) {
tracks = items; tracks = items;
@@ -103,12 +105,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
try { try {
tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id); tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title); final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title);
if (mounted) showErrorSnackBar(context, message); showErrorSnackBar(context, message);
return; return;
} }
if (!mounted) return;
} }
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle); await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle);
} }
+36 -5
View File
@@ -9,8 +9,9 @@ enum MusicRepeatMode { off, all, one }
/// Coarse playback state of the music session. /// Coarse playback state of the music session.
enum MusicPlaybackStatus { idle, loading, playing, paused, error } enum MusicPlaybackStatus { idle, loading, playing, paused, error }
/// What kind of container playback was started from — drives the /// What kind of container playback was started from. The player keeps
/// "Playing from …" line in the player UI. /// artist/playlist/mix provenance stable, while album and ad-hoc queues use
/// the active track's album for the "Playing from …" line.
enum MusicPlayContextKind { album, artist, playlist, mix, tracks } enum MusicPlayContextKind { album, artist, playlist, mix, tracks }
/// Provenance of the current queue (album/artist/playlist/instant mix). /// Provenance of the current queue (album/artist/playlist/instant mix).
@@ -19,7 +20,8 @@ class MusicPlayContext {
/// don't). /// don't).
final String? id; final String? id;
/// Display title ("Playing from {title}"). /// Display title of the session source. Used directly for stable
/// artist/playlist/mix provenance labels.
final String title; final String title;
final MusicPlayContextKind kind; final MusicPlayContextKind kind;
@@ -65,6 +67,18 @@ abstract class MusicPlaybackService extends ChangeNotifier {
/// handles recovery (skip / stop) itself. /// handles recovery (skip / stop) itself.
Stream<Object> get errors; Stream<Object> get errors;
/// Claims the latest user intent to replace playback after asynchronous
/// queue construction. Callers must check [isPlayIntentCurrent] before
/// committing fetched tracks.
int beginPlayIntent();
/// Whether [intent] is still the latest playback-replacement request.
bool isPlayIntentCurrent(int intent);
/// Changes whenever a queue session starts or stops. Asynchronous enqueue
/// actions use this to avoid appending fetched tracks to a newer session.
int get queueSessionRevision;
/// Start a new queue from [tracks], optionally at [startTrack] (defaults /// Start a new queue from [tracks], optionally at [startTrack] (defaults
/// to the first track). [shuffle] shuffles with the start track anchored /// to the first track). [shuffle] shuffles with the start track anchored
/// first. /// first.
@@ -146,6 +160,8 @@ abstract class MusicPlaybackService extends ChangeNotifier {
/// null-safe without per-call-site feature checks. /// null-safe without per-call-site feature checks.
class StubMusicPlaybackService extends MusicPlaybackService { class StubMusicPlaybackService extends MusicPlaybackService {
final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100); final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100);
int _playIntentGeneration = 0;
int _queueSessionRevision = 0;
@override @override
bool get isAvailable => false; bool get isAvailable => false;
@@ -182,13 +198,25 @@ class StubMusicPlaybackService extends MusicPlaybackService {
@override @override
Stream<Object> get errors => const Stream.empty(); Stream<Object> get errors => const Stream.empty();
@override
int beginPlayIntent() => ++_playIntentGeneration;
@override
bool isPlayIntentCurrent(int intent) => intent == _playIntentGeneration;
@override
int get queueSessionRevision => _queueSessionRevision;
@override @override
Future<void> playFromList({ Future<void> playFromList({
required List<MediaItem> tracks, required List<MediaItem> tracks,
MediaItem? startTrack, MediaItem? startTrack,
required MusicPlayContext playContext, required MusicPlayContext playContext,
bool shuffle = false, bool shuffle = false,
}) async {} }) async {
beginPlayIntent();
_queueSessionRevision++;
}
@override @override
Future<void> playInstantMix(MediaItem seed) async {} Future<void> playInstantMix(MediaItem seed) async {}
@@ -244,7 +272,10 @@ class StubMusicPlaybackService extends MusicPlaybackService {
void clearUpcoming() {} void clearUpcoming() {}
@override @override
Future<void> stop() async {} Future<void> stop() async {
beginPlayIntent();
_queueSessionRevision++;
}
@override @override
bool get sleepTimerActive => false; bool get sleepTimerActive => false;
@@ -137,6 +137,20 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// (resolves, opens, arms) drop out instead of acting on the new state. /// (resolves, opens, arms) drop out instead of acting on the new state.
int _generation = 0; int _generation = 0;
/// Queue construction can involve a server round-trip before playback is
/// replaced. Only the latest explicit play intent may commit its result.
int _playIntentGeneration = 0;
/// Identifies the queue session that asynchronous enqueue work belongs to.
int _queueSessionRevision = 0;
/// Gapless arm work is serialized, and each requested recomputation gets
/// a revision. This prevents an older resolve/setNext continuation from
/// landing after a queue edit, repeat change, or sleep-timer change.
int _armRequestGeneration = 0;
int _processedArmRequestGeneration = 0;
Future<void>? _armDrain;
int _consecutiveFailures = 0; int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false; bool _resumeAfterInterruption = false;
bool _disposed = false; bool _disposed = false;
@@ -208,6 +222,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
@override @override
bool get sleepTimerEndOfTrack => _sleepTimerEndOfTrack; bool get sleepTimerEndOfTrack => _sleepTimerEndOfTrack;
@override
int beginPlayIntent() => ++_playIntentGeneration;
@override
bool isPlayIntentCurrent(int intent) => !_disposed && intent == _playIntentGeneration;
@override
int get queueSessionRevision => _queueSessionRevision;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Session start // Session start
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -219,25 +242,30 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
required MusicPlayContext playContext, required MusicPlayContext playContext,
bool shuffle = false, bool shuffle = false,
}) { }) {
beginPlayIntent();
return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle); return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
} }
@override @override
Future<void> playInstantMix(MediaItem seed) async { Future<void> playInstantMix(MediaItem seed) async {
final intent = beginPlayIntent();
final client = _clientFor(seed); final client = _clientFor(seed);
if (client == null) { if (client == null) {
if (isPlayIntentCurrent(intent)) {
_errorsController.add(StateError('No server available for instant mix')); _errorsController.add(StateError('No server available for instant mix'));
}
return; return;
} }
List<MediaItem> tracks; List<MediaItem> tracks;
try { try {
tracks = await client.fetchInstantMix(seed.id); tracks = await client.fetchInstantMix(seed.id);
} catch (e, st) { } catch (e, st) {
if (!isPlayIntentCurrent(intent)) return;
appLogger.w('Instant mix fetch failed for ${seed.id}', error: e, stackTrace: st); appLogger.w('Instant mix fetch failed for ${seed.id}', error: e, stackTrace: st);
_errorsController.add(e); _errorsController.add(e);
return; return;
} }
if (_disposed || tracks.isEmpty) return; if (!isPlayIntentCurrent(intent) || tracks.isEmpty) return;
await _startQueue( await _startQueue(
tracks: tracks, tracks: tracks,
playContext: MusicPlayContext(title: seed.displayTitle, kind: MusicPlayContextKind.mix), playContext: MusicPlayContext(title: seed.displayTitle, kind: MusicPlayContextKind.mix),
@@ -252,11 +280,14 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
bool autoplay = true, bool autoplay = true,
}) async { }) async {
if (tracks.isEmpty || _disposed) return; if (tracks.isEmpty || _disposed) return;
beginPlayIntent();
_queueSessionRevision++;
// Android 13+: the background playback notification needs // Android 13+: the background playback notification needs
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground // POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
// service run regardless; a denial only hides the notification. // service run regardless; a denial only hides the notification.
unawaited(ensureNotificationPermission()); unawaited(ensureNotificationPermission());
final generation = ++_generation; final generation = ++_generation;
_invalidateArmRequests();
_finalizeCurrentTrack(); _finalizeCurrentTrack();
var startIndex = 0; var startIndex = 0;
if (startTrack != null) { if (startTrack != null) {
@@ -333,13 +364,14 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused); _setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
_bindTrackServices(track, source); _bindTrackServices(track, source);
unawaited(_armNext(generation)); _requestArmNext();
} }
/// Manual advance: finalize the current tracker at its current position and /// Manual advance: finalize the current tracker at its current position and
/// open the queue entry at [cursor]. /// open the queue entry at [cursor].
Future<void> _advanceTo(int cursor, {bool play = true}) async { Future<void> _advanceTo(int cursor, {bool play = true}) async {
final generation = ++_generation; final generation = ++_generation;
_invalidateArmRequests();
_finalizeCurrentTrack(); _finalizeCurrentTrack();
_queue.jumpTo(cursor); _queue.jumpTo(cursor);
await _openCurrent(generation, play: play); await _openCurrent(generation, play: play);
@@ -348,9 +380,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// Arm (or clear) what the backend should auto-advance into. Skips the /// Arm (or clear) what the backend should auto-advance into. Skips the
/// resolve round-trip when the desired target is already armed; repeat-one /// resolve round-trip when the desired target is already armed; repeat-one
/// reuses the current track's resolved source. /// reuses the current track's resolved source.
Future<void> _armNext(int generation) async { Future<void> _applyArmNext(int generation, int armRequest) async {
final player = _player; final player = _player;
if (player == null || generation != _generation) return; if (player == null || !_isCurrentArmRequest(player, generation, armRequest)) return;
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex(); final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
final target = targetCursor == null ? null : _queue.trackAt(targetCursor); final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
@@ -366,7 +398,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_rememberStaleArm(); _rememberStaleArm();
await _trySetNext(player, null); await _trySetNext(player, null);
if (generation != _generation || _player != player) return; if (!_isCurrentArmRequest(player, generation, armRequest)) return;
MusicSource source; MusicSource source;
if (targetCursor == _queue.cursor && _currentSource != null) { if (targetCursor == _queue.cursor && _currentSource != null) {
@@ -382,11 +414,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
return; return;
} }
} }
if (generation != _generation || _player != player) return; if (!_isCurrentArmRequest(player, generation, armRequest)) return;
_armed = _ArmedTrack(track: target, source: source); final armed = _ArmedTrack(track: target, source: source);
_armed = armed;
appLogger.d('Music: arming cursor $targetCursor "${target.title}"'); appLogger.d('Music: arming cursor $targetCursor "${target.title}"');
final ok = await _trySetNext(player, Media(source.url, headers: source.headers)); final ok = await _trySetNext(player, Media(source.url, headers: source.headers));
if (!ok && generation == _generation && _player == player) { if (!ok && identical(_armed, armed)) {
// Nothing is armed natively; clear the record so the confirmed // Nothing is armed natively; clear the record so the confirmed
// completed fallback can advance explicitly instead of waiting for a // completed fallback can advance explicitly instead of waiting for a
// transition that can never come. // transition that can never come.
@@ -394,6 +427,45 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
} }
} }
bool _isCurrentArmRequest(Player player, int generation, int armRequest) {
return !_disposed && generation == _generation && armRequest == _armRequestGeneration && _player == player;
}
void _requestArmNext() {
if (_disposed) return;
_armRequestGeneration++;
_ensureArmDrain();
}
void _invalidateArmRequests() {
_armRequestGeneration++;
}
void _ensureArmDrain() {
if (_disposed || _armDrain != null) return;
final drain = _drainArmRequests();
_armDrain = drain;
unawaited(
drain.whenComplete(() {
if (_armDrain != drain) return;
_armDrain = null;
if (!_disposed && _processedArmRequestGeneration != _armRequestGeneration) {
_ensureArmDrain();
}
}),
);
}
Future<void> _drainArmRequests() async {
while (!_disposed) {
final armRequest = _armRequestGeneration;
final generation = _generation;
await _applyArmNext(generation, armRequest);
_processedArmRequestGeneration = armRequest;
if (armRequest == _armRequestGeneration) return;
}
}
/// Un-arm bookkeeping: [_armed] is cleared but remembered so /// Un-arm bookkeeping: [_armed] is cleared but remembered so
/// [_onTrackTransition] can adopt a transition that raced the clear. /// [_onTrackTransition] can adopt a transition that raced the clear.
void _rememberStaleArm() { void _rememberStaleArm() {
@@ -418,11 +490,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// edits that keep the same next track cost no server round-trip. /// edits that keep the same next track cost no server round-trip.
void _rearmIfNeeded() { void _rearmIfNeeded() {
if (_player == null || _currentTrack == null) return; if (_player == null || _currentTrack == null) return;
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex(); _requestArmNext();
final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
if (target == null && _armed == null) return;
if (target != null && _armed?.track.globalKey == target.globalKey) return;
unawaited(_armNext(_generation));
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -501,7 +569,8 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
} }
final adopted = armed; final adopted = armed;
_armed = null; _armed = null;
final generation = ++_generation; _generation++;
_invalidateArmRequests();
// The finished track played out fully — report stopped at its duration. // The finished track played out fully — report stopped at its duration.
final finishedMs = _currentTrack?.durationMs; final finishedMs = _currentTrack?.durationMs;
@@ -537,7 +606,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
appLogger.d('Music: transition received "${adopted.track.title}" → cursor ${_queue.cursor}'); appLogger.d('Music: transition received "${adopted.track.title}" → cursor ${_queue.cursor}');
_setStatus(MusicPlaybackStatus.playing, forceNotify: true); _setStatus(MusicPlaybackStatus.playing, forceNotify: true);
_bindTrackServices(_currentTrack!, adopted.source); _bindTrackServices(_currentTrack!, adopted.source);
unawaited(_armNext(generation)); _requestArmNext();
} }
/// Completed (eof-reached) is NOT a last-entry-only signal: mpv pulses it /// Completed (eof-reached) is NOT a last-entry-only signal: mpv pulses it
@@ -591,6 +660,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// remains; pressing play restarts the current track from the top. /// remains; pressing play restarts the current track from the top.
void _parkAtEnd() { void _parkAtEnd() {
_generation++; _generation++;
_invalidateArmRequests();
final finishedMs = _currentTrack?.durationMs; final finishedMs = _currentTrack?.durationMs;
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null); _finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
_setStatus(MusicPlaybackStatus.paused, forceNotify: true); _setStatus(MusicPlaybackStatus.paused, forceNotify: true);
@@ -778,28 +848,37 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
Future<void> play() async { Future<void> play() async {
final player = _player; final player = _player;
if (player == null || _currentTrack == null) return; if (player == null || _currentTrack == null) return;
final generation = _generation;
if (player.state.completed) { if (player.state.completed) {
// Parked at queue end: restart the current track. // Parked at queue end: restart the current track.
await player.seek(Duration.zero); await player.seek(Duration.zero);
if (!_isCurrentTransport(player, generation)) return;
final currentTrack = _currentTrack; final currentTrack = _currentTrack;
final currentSource = _currentSource; final currentSource = _currentSource;
if (currentTrack != null && currentSource != null) { if (currentTrack != null && currentSource != null) {
_bindTrackServices(currentTrack, currentSource); _bindTrackServices(currentTrack, currentSource);
} }
unawaited(_armNext(_generation)); _requestArmNext();
} }
await player.play(); await player.play();
if (!_isCurrentTransport(player, generation)) return;
_setStatus(MusicPlaybackStatus.playing); _setStatus(MusicPlaybackStatus.playing);
} }
@override @override
Future<void> pause() async { Future<void> pause() async {
final player = _player; final player = _player;
if (player == null) return; if (player == null || _currentTrack == null) return;
final generation = _generation;
await player.pause(); await player.pause();
if (!_isCurrentTransport(player, generation)) return;
_setStatus(MusicPlaybackStatus.paused); _setStatus(MusicPlaybackStatus.paused);
} }
bool _isCurrentTransport(Player player, int generation) {
return !_disposed && _currentTrack != null && generation == _generation && _player == player;
}
@override @override
Future<void> togglePlayPause() { Future<void> togglePlayPause() {
final player = _player; final player = _player;
@@ -1031,7 +1110,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
// End-of-track mode suppresses gapless arming (and leaving it restores // End-of-track mode suppresses gapless arming (and leaving it restores
// the arm), so the track genuinely completes instead of transitioning. // the arm), so the track genuinely completes instead of transitioning.
if (hadEndOfTrack != _sleepTimerEndOfTrack) { if (hadEndOfTrack != _sleepTimerEndOfTrack) {
unawaited(_armNext(_generation)); _requestArmNext();
} }
notifyListeners(); notifyListeners();
} }
@@ -1064,7 +1143,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
Future<void> _stopForVideoClaim() => _stopSession(endStatus: MusicPlaybackStatus.idle); Future<void> _stopForVideoClaim() => _stopSession(endStatus: MusicPlaybackStatus.idle);
Future<void> _stopSession({required MusicPlaybackStatus endStatus}) async { Future<void> _stopSession({required MusicPlaybackStatus endStatus}) async {
beginPlayIntent();
_queueSessionRevision++;
_generation++; _generation++;
_invalidateArmRequests();
_completedConfirmTimer?.cancel(); _completedConfirmTimer?.cancel();
_completedConfirmTimer = null; _completedConfirmTimer = null;
_cancelSleepTimer(); _cancelSleepTimer();
@@ -1076,6 +1158,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_staleArm = null; _staleArm = null;
_playContext = null; _playContext = null;
_resumeAfterInterruption = false; _resumeAfterInterruption = false;
_setStatus(endStatus, forceNotify: true);
final player = _player; final player = _player;
_player = null; _player = null;
@@ -1110,8 +1193,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
unawaited(controls.clear()); unawaited(controls.clear());
controls.dispose(); controls.dispose();
} }
_setStatus(endStatus, forceNotify: true);
} }
@override @override
@@ -1137,6 +1218,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
@override @override
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_playIntentGeneration++;
_queueSessionRevision++;
_generation++;
_invalidateArmRequests();
_disposed = true; _disposed = true;
if (_observesLifecycle) { if (_observesLifecycle) {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
+11 -2
View File
@@ -564,6 +564,8 @@ class PlexMetadataDto {
final double? userRating; final double? userRating;
@JsonKey(fromJson: flexibleInt) @JsonKey(fromJson: flexibleInt)
final int? year; final int? year;
@JsonKey(fromJson: flexibleInt)
final int? parentYear;
final String? originallyAvailableAt; final String? originallyAvailableAt;
final String? thumb; final String? thumb;
final String? art; final String? art;
@@ -666,6 +668,7 @@ class PlexMetadataDto {
this.audienceRating, this.audienceRating,
this.userRating, this.userRating,
this.year, this.year,
this.parentYear,
this.originallyAvailableAt, this.originallyAvailableAt,
this.thumb, this.thumb,
this.art, this.art,
@@ -793,6 +796,7 @@ class PlexMetadataDto {
double? audienceRating, double? audienceRating,
double? userRating, double? userRating,
int? year, int? year,
int? parentYear,
String? originallyAvailableAt, String? originallyAvailableAt,
String? thumb, String? thumb,
String? art, String? art,
@@ -862,6 +866,7 @@ class PlexMetadataDto {
audienceRating: audienceRating ?? this.audienceRating, audienceRating: audienceRating ?? this.audienceRating,
userRating: userRating ?? this.userRating, userRating: userRating ?? this.userRating,
year: year ?? this.year, year: year ?? this.year,
parentYear: parentYear ?? this.parentYear,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
thumb: thumb ?? this.thumb, thumb: thumb ?? this.thumb,
art: art ?? this.art, art: art ?? this.art,
@@ -961,9 +966,10 @@ class PlexMappers {
/// Map a parsed [PlexMetadataDto] into a [PlexMediaItem]. /// Map a parsed [PlexMetadataDto] into a [PlexMediaItem].
static PlexMediaItem mediaItem(PlexMetadataDto dto) { static PlexMediaItem mediaItem(PlexMetadataDto dto) {
final kind = MediaKind.fromString(dto.type);
return PlexMediaItem( return PlexMediaItem(
id: dto.ratingKey, id: dto.ratingKey,
kind: MediaKind.fromString(dto.type), kind: kind,
guid: dto.guid, guid: dto.guid,
title: dto.title, title: dto.title,
titleSort: dto.titleSort, titleSort: dto.titleSort,
@@ -972,7 +978,10 @@ class PlexMappers {
originalTitle: dto.originalTitle, originalTitle: dto.originalTitle,
editionTitle: dto.editionTitle, editionTitle: dto.editionTitle,
studio: dto.studio, studio: dto.studio,
year: dto.year, // Plex stores an ordinary track's release year on the parent album.
// Normalize it into the neutral item's year, matching Jellyfin Audio
// rows, while leaving episode/season hierarchy semantics unchanged.
year: kind == MediaKind.track ? dto.year ?? dto.parentYear : dto.year,
originallyAvailableAt: dto.originallyAvailableAt, originallyAvailableAt: dto.originallyAvailableAt,
contentRating: dto.contentRating, contentRating: dto.contentRating,
parentId: dto.parentRatingKey, parentId: dto.parentRatingKey,
+2
View File
@@ -93,6 +93,7 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
audienceRating: flexibleDouble(json['audienceRating']), audienceRating: flexibleDouble(json['audienceRating']),
userRating: flexibleDouble(json['userRating']), userRating: flexibleDouble(json['userRating']),
year: flexibleInt(json['year']), year: flexibleInt(json['year']),
parentYear: flexibleInt(json['parentYear']),
originallyAvailableAt: json['originallyAvailableAt'] as String?, originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?, thumb: json['thumb'] as String?,
art: json['art'] as String?, art: json['art'] as String?,
@@ -167,6 +168,7 @@ Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
'audienceRating': ?instance.audienceRating, 'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating, 'userRating': ?instance.userRating,
'year': ?instance.year, 'year': ?instance.year,
'parentYear': ?instance.parentYear,
'originallyAvailableAt': ?instance.originallyAvailableAt, 'originallyAvailableAt': ?instance.originallyAvailableAt,
'thumb': ?instance.thumb, 'thumb': ?instance.thumb,
'art': ?instance.art, 'art': ?instance.art,
+5 -1
View File
@@ -123,6 +123,8 @@ Future<void> playTracks(
/// album, isn't found in it, or the album fetch fails. /// album, isn't found in it, or the album fetch fails.
Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) async { Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) async {
if (!ensureMusicPlaybackAvailable(context)) return; if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
final intent = service.beginPlayIntent();
final albumId = track.parentId; final albumId = track.parentId;
final client = context.getMediaClientForItemOrNull(track); final client = context.getMediaClientForItemOrNull(track);
@@ -130,7 +132,7 @@ Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) as
try { try {
final tracks = await client.fetchAlbumTracks(albumId); final tracks = await client.fetchAlbumTracks(albumId);
final startIndex = tracks.indexWhere((item) => item.id == track.id); final startIndex = tracks.indexWhere((item) => item.id == track.id);
if (!context.mounted) return; if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
if (startIndex != -1) { if (startIndex != -1) {
await playTracks( await playTracks(
context, context,
@@ -141,11 +143,13 @@ Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) as
return; return;
} }
} catch (e) { } catch (e) {
if (!service.isPlayIntentCurrent(intent)) return;
appLogger.w('Failed to fetch album context for track ${track.id}; playing single track', error: e); appLogger.w('Failed to fetch album context for track ${track.id}; playing single track', error: e);
if (!context.mounted) return; if (!context.mounted) return;
} }
} }
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
await playTracks( await playTracks(
context, context,
tracks: [track], tracks: [track],
+23 -7
View File
@@ -1062,8 +1062,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
// Availability gate before the container fetch so the stub costs no // Availability gate before the container fetch so the stub costs no
// server round-trip. // server round-trip.
if (!ensureMusicPlaybackAvailable(context)) return; if (!ensureMusicPlaybackAvailable(context)) return;
final tracks = await _musicTracksForItem(item); final service = context.read<MusicPlaybackService>();
if (!context.mounted) return; final intent = service.beginPlayIntent();
List<MediaItem> tracks;
try {
tracks = await _musicTracksForItem(item);
} catch (_) {
if (!service.isPlayIntentCurrent(intent)) return;
rethrow;
}
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
await playTracks( await playTracks(
context, context,
tracks: tracks, tracks: tracks,
@@ -1079,8 +1087,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final service = context.read<MusicPlaybackService?>(); final service = context.read<MusicPlaybackService?>();
// Menu entries are hidden on the stub; defensive re-check. // Menu entries are hidden on the stub; defensive re-check.
if (service == null || !service.isAvailable) return; if (service == null || !service.isAvailable) return;
final tracks = await _musicTracksForItem(_mediaItem!); final queueSessionRevision = service.queueSessionRevision;
if (tracks.isEmpty) return; List<MediaItem> tracks;
try {
tracks = await _musicTracksForItem(_mediaItem!);
} catch (_) {
if (!context.mounted || service.queueSessionRevision != queueSessionRevision) return;
rethrow;
}
if (!context.mounted || service.queueSessionRevision != queueSessionRevision || tracks.isEmpty) return;
if (playNext) { if (playNext) {
service.addNext(tracks); service.addNext(tracks);
} else { } else {
@@ -1383,18 +1398,19 @@ class MediaContextMenuState extends State<MediaContextMenu> {
// Match PlaylistDetailScreen: fail the availability gate before paying // Match PlaylistDetailScreen: fail the availability gate before paying
// for a full playlist fetch, then hand the tracks to the music session. // for a full playlist fetch, then hand the tracks to the music session.
if (!ensureMusicPlaybackAvailable(context)) return; if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
final intent = service.beginPlayIntent();
List<MediaItem> tracks; List<MediaItem> tracks;
try { try {
tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id); tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id);
} catch (e, st) { } catch (e, st) {
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st);
if (context.mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
return; return;
} }
if (!context.mounted) return; if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
if (tracks.isEmpty) { if (tracks.isEmpty) {
showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems);
return; return;
+2 -2
View File
@@ -286,7 +286,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
height: _MusicMiniPlayerOverlayState._cardHeight, height: _MusicMiniPlayerOverlayState._cardHeight,
child: Stack( child: Stack(
children: [ children: [
const Positioned.fill(child: _MiniPlayerProgress()), Positioned.fill(child: _MiniPlayerProgress(key: ValueKey(widget.track.globalKey))),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row( child: Row(
@@ -409,7 +409,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
/// background layer, never the card content above it. The played fraction /// background layer, never the card content above it. The played fraction
/// renders as a subtle full-height tint that fills the card left-to-right. /// renders as a subtle full-height tint that fills the card left-to-right.
class _MiniPlayerProgress extends StatelessWidget { class _MiniPlayerProgress extends StatelessWidget {
const _MiniPlayerProgress(); const _MiniPlayerProgress({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+14
View File
@@ -15,6 +15,7 @@ import '../test_helpers/media_items.dart';
MediaItem _movie({ MediaItem _movie({
String id = 'm1', String id = 'm1',
String? title = 'Movie', String? title = 'Movie',
int? year,
int? viewCount, int? viewCount,
int? leafCount, int? leafCount,
int? viewedLeafCount, int? viewedLeafCount,
@@ -29,6 +30,7 @@ MediaItem _movie({
backend: backend, backend: backend,
kind: MediaKind.movie, kind: MediaKind.movie,
title: title, title: title,
year: year,
viewCount: viewCount, viewCount: viewCount,
leafCount: leafCount, leafCount: leafCount,
viewedLeafCount: viewedLeafCount, viewedLeafCount: viewedLeafCount,
@@ -446,4 +448,16 @@ void main() {
expect(movie.displayTitle, ''); 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.trackNumber, 1);
expect(item.discNumber, 1); expect(item.discNumber, 1);
expect(item.albumTitle, 'Live at Testhalle'); expect(item.albumTitle, 'Live at Testhalle');
expect(item.albumYear, 2022);
expect(item.albumArtistTitle, 'The Synth Pops'); expect(item.albumArtistTitle, 'The Synth Pops');
// Artists == [AlbumArtist] → no per-track performer override. // Artists == [AlbumArtist] → no per-track performer override.
expect(item.originalTitle, isNull); expect(item.originalTitle, isNull);
@@ -89,6 +89,8 @@ class FakePlayer implements Player {
final List<Media?> setNextCalls = []; final List<Media?> setNextCalls = [];
final List<Duration> seeks = []; final List<Duration> seeks = [];
final List<double> volumes = []; final List<double> volumes = [];
Completer<void>? playGate;
Completer<void>? pauseGate;
/// Arming these URIs throws, simulating a native setNext failure. /// Arming these URIs throws, simulating a native setNext failure.
final Set<String> failingSetNextUris = {}; final Set<String> failingSetNextUris = {};
@@ -178,6 +180,7 @@ class FakePlayer implements Player {
@override @override
Future<void> play() async { Future<void> play() async {
playCalls++; playCalls++;
await playGate?.future;
_state = _state.copyWith(playing: true, completed: false); _state = _state.copyWith(playing: true, completed: false);
playingCtrl.add(true); playingCtrl.add(true);
} }
@@ -185,6 +188,7 @@ class FakePlayer implements Player {
@override @override
Future<void> pause() async { Future<void> pause() async {
pauseCalls++; pauseCalls++;
await pauseGate?.future;
_state = _state.copyWith(playing: false); _state = _state.copyWith(playing: false);
playingCtrl.add(false); playingCtrl.add(false);
} }
@@ -351,6 +355,7 @@ class RecordedReport {
class FakeMediaServerClient extends Fake implements MediaServerClient { class FakeMediaServerClient extends Fake implements MediaServerClient {
final List<RecordedReport> reports = []; final List<RecordedReport> reports = [];
final List<String> markedWatched = []; final List<String> markedWatched = [];
Completer<List<MediaItem>>? instantMixGate;
Iterable<RecordedReport> reportsFor(String state) => reports.where((r) => r.state == state); Iterable<RecordedReport> reportsFor(String state) => reports.where((r) => r.state == state);
@@ -363,6 +368,14 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
@override @override
bool get marksWatchedOnPlaybackStopped => false; bool get marksWatchedOnPlaybackStopped => false;
@override
void close() {}
@override
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100}) {
return instantMixGate?.future ?? Future.value(const []);
}
@override @override
Future<void> markWatched(MediaItem item) async { Future<void> markWatched(MediaItem item) async {
markedWatched.add(item.id); markedWatched.add(item.id);
@@ -419,6 +432,7 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
final MediaServerClient? client; final MediaServerClient? client;
final Set<String> failingIds = {}; final Set<String> failingIds = {};
final Map<String, int> resolveCounts = {}; final Map<String, int> resolveCounts = {};
final Map<String, Completer<void>> resolveGates = {};
/// Per-track URL overrides (e.g. content:// shapes for offline tracks). /// Per-track URL overrides (e.g. content:// shapes for offline tracks).
final Map<String, String> urlOverrides = {}; final Map<String, String> urlOverrides = {};
@@ -426,6 +440,7 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
@override @override
Future<MusicSource> resolve(MediaItem track) async { Future<MusicSource> resolve(MediaItem track) async {
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1; resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
await resolveGates[track.id]?.future;
if (failingIds.contains(track.id)) { if (failingIds.contains(track.id)) {
throw StateError('resolve failed for ${track.id}'); throw StateError('resolve failed for ${track.id}');
} }
@@ -510,13 +525,14 @@ class _GatedVolumeWriter {
} }
class _Harness { 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 MusicPlaybackServiceImpl service;
final FakeMusicSourceResolver resolver; final FakeMusicSourceResolver resolver;
final FakeMediaServerClient client; final FakeMediaServerClient client;
final FakeMediaControlsManager controls; final FakeMediaControlsManager controls;
final List<FakePlayer> players; final List<FakePlayer> players;
final MultiServerManager serverManager;
/// Seeded into every created FakePlayer — lets a test configure arm /// Seeded into every created FakePlayer — lets a test configure arm
/// failures before the first player exists. /// failures before the first player exists.
@@ -529,9 +545,10 @@ class _Harness {
final resolver = FakeMusicSourceResolver(client: client); final resolver = FakeMusicSourceResolver(client: client);
final controls = FakeMediaControlsManager(); final controls = FakeMediaControlsManager();
final players = <FakePlayer>[]; final players = <FakePlayer>[];
final serverManager = MultiServerManager()..debugRegisterClientForTesting(client);
late final _Harness harness; late final _Harness harness;
final service = MusicPlaybackServiceImpl( final service = MusicPlaybackServiceImpl(
serverManager: MultiServerManager(), serverManager: serverManager,
resolver: resolver, resolver: resolver,
audioPlayerFactory: () { audioPlayerFactory: () {
final player = FakePlayer(); final player = FakePlayer();
@@ -545,7 +562,7 @@ class _Harness {
completedConfirmDelay: Duration.zero, completedConfirmDelay: Duration.zero,
volumePersistenceWriter: volumePersistenceWriter, volumePersistenceWriter: volumePersistenceWriter,
); );
harness = _Harness._(service, resolver, client, controls, players); harness = _Harness._(service, resolver, client, controls, players, serverManager);
return harness; return harness;
} }
@@ -579,6 +596,7 @@ void main() {
player.closeControllers(); player.closeControllers();
} }
h.controls.closeControllers(); h.controls.closeControllers();
h.serverManager.dispose();
}); });
test('volume updates notify only the dedicated volume listenable', () async { test('volume updates notify only the dedicated volume listenable', () async {
@@ -687,6 +705,47 @@ void main() {
expect(h.controls.metadataTitles, ['Track t1']); 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 { test('trackTransition advances the cursor, re-arms, and reports the previous track stopped at duration', () async {
await h.playTracks([t1, t2, t3]); await h.playTracks([t1, t2, t3]);
@@ -876,6 +935,35 @@ void main() {
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']); 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 { test('interruption pauses and resumes when the system says shouldResume', () async {
await h.playTracks([t1, t2]); await h.playTracks([t1, t2]);
+5
View File
@@ -243,6 +243,7 @@ void main() {
'parentIndex': 1, 'parentIndex': 1,
'parentRatingKey': '510', 'parentRatingKey': '510',
'parentTitle': 'Season 1', 'parentTitle': 'Season 1',
'parentYear': 2008,
'parentThumb': '/library/metadata/510/thumb/1', 'parentThumb': '/library/metadata/510/thumb/1',
'grandparentRatingKey': '500', 'grandparentRatingKey': '500',
'grandparentTitle': 'Breaking Bad', 'grandparentTitle': 'Breaking Bad',
@@ -258,6 +259,7 @@ void main() {
expect(item.parentIndex, 1); expect(item.parentIndex, 1);
expect(item.parentId, '510'); expect(item.parentId, '510');
expect(item.parentTitle, 'Season 1'); expect(item.parentTitle, 'Season 1');
expect(item.year, isNull);
expect(item.parentThumbPath, '/library/metadata/510/thumb/1'); expect(item.parentThumbPath, '/library/metadata/510/thumb/1');
expect(item.grandparentId, '500'); expect(item.grandparentId, '500');
expect(item.grandparentTitle, 'Breaking Bad'); expect(item.grandparentTitle, 'Breaking Bad');
@@ -299,6 +301,7 @@ void main() {
'index': 8, 'index': 8,
'parentRatingKey': '700', 'parentRatingKey': '700',
'parentTitle': 'Random Access Memories', 'parentTitle': 'Random Access Memories',
'parentYear': 2013,
'grandparentRatingKey': '699', 'grandparentRatingKey': '699',
'grandparentTitle': 'Daft Punk', 'grandparentTitle': 'Daft Punk',
'duration': 369000, 'duration': 369000,
@@ -311,6 +314,8 @@ void main() {
expect(item.durationMs, 369000); expect(item.durationMs, 369000);
expect(item.parentId, '700'); expect(item.parentId, '700');
expect(item.parentTitle, 'Random Access Memories'); expect(item.parentTitle, 'Random Access Memories');
expect(item.year, 2013);
expect(item.albumYear, 2013);
expect(item.grandparentId, '699'); expect(item.grandparentId, '699');
expect(item.grandparentTitle, 'Daft Punk'); expect(item.grandparentTitle, 'Daft Punk');
}); });
+89 -2
View File
@@ -1,4 +1,5 @@
import '../test_helpers/paged_fakes.dart'; import '../test_helpers/paged_fakes.dart';
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
@@ -200,6 +201,30 @@ void main() {
expect(music.callCount, 2); expect(music.callCount, 2);
expect(music.playedTracks, tracks); expect(music.playedTracks, tracks);
expect(music.shuffle, isTrue); 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); expect(tester.takeException(), isNull);
}); });
@@ -408,6 +433,49 @@ void main() {
same(harness.music), 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 { class _AudioPlaylistClient implements MediaServerClient {
final List<MediaItem> tracks; final List<MediaItem> tracks;
Completer<void>? fetchGate;
_AudioPlaylistClient(this.tracks); _AudioPlaylistClient(this.tracks);
@@ -539,6 +608,7 @@ class _AudioPlaylistClient implements MediaServerClient {
@override @override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
await fetchGate?.future;
return fakeLibraryPage(tracks, start: start, size: size); return fakeLibraryPage(tracks, start: start, size: size);
} }
@@ -551,6 +621,7 @@ class _AudioPlaylistClient implements MediaServerClient {
class _RecordingMusicPlaybackService extends StubMusicPlaybackService { class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
List<MediaItem>? playedTracks; List<MediaItem>? playedTracks;
final List<MediaItem> addedNext = [];
MusicPlayContext? playedContext; MusicPlayContext? playedContext;
bool? shuffle; bool? shuffle;
int callCount = 0; int callCount = 0;
@@ -565,17 +636,27 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
required MusicPlayContext playContext, required MusicPlayContext playContext,
bool shuffle = false, bool shuffle = false,
}) async { }) async {
await super.playFromList(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
callCount++; callCount++;
playedTracks = tracks; playedTracks = tracks;
playedContext = playContext; playedContext = playContext;
this.shuffle = shuffle; this.shuffle = shuffle;
} }
@override
void addNext(List<MediaItem> tracks) {
addedNext.addAll(tracks);
}
} }
class _RelatedMusicClient implements MediaServerClient { 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 Map<String, MediaItem> _items;
final List<MediaItem> albumTracks;
Completer<void>? albumTracksGate;
@override @override
ServerId get serverId => ServerId('srv-1'); ServerId get serverId => ServerId('srv-1');
@@ -593,7 +674,10 @@ class _RelatedMusicClient implements MediaServerClient {
Future<MediaItem?> fetchItem(String id) async => _items[id]; Future<MediaItem?> fetchItem(String id) async => _items[id];
@override @override
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async => const []; Future<List<MediaItem>> fetchAlbumTracks(String albumId) async {
await albumTracksGate?.future;
return albumTracks;
}
@override @override
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist) async => const []; Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist) async => const [];
@@ -611,12 +695,14 @@ class _SiblingMusicMenuHarness {
required this.profileNavigatorKey, required this.profileNavigatorKey,
required this.menuKey, required this.menuKey,
required this.music, required this.music,
required this.client,
}); });
final GlobalKey<NavigatorState> rootNavigatorKey; final GlobalKey<NavigatorState> rootNavigatorKey;
final GlobalKey<NavigatorState> profileNavigatorKey; final GlobalKey<NavigatorState> profileNavigatorKey;
final GlobalKey<MediaContextMenuState> menuKey; final GlobalKey<MediaContextMenuState> menuKey;
final _RecordingMusicPlaybackService music; final _RecordingMusicPlaybackService music;
final _RelatedMusicClient client;
} }
Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
@@ -722,6 +808,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
profileNavigatorKey: profileNavigatorKey, profileNavigatorKey: profileNavigatorKey,
menuKey: menuKey, menuKey: menuKey,
music: music, music: music,
client: client,
); );
} }
+58
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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. /// Fixed-state fake: reports a playing session with a single-track queue.
class _FakeMusicService extends StubMusicPlaybackService { class _FakeMusicService extends StubMusicPlaybackService {
MediaItem? track; MediaItem? track;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
Duration _position = Duration.zero;
int previousCalls = 0; int previousCalls = 0;
int toggleCalls = 0; int toggleCalls = 0;
int nextCalls = 0; int nextCalls = 0;
@@ -85,6 +89,12 @@ class _FakeMusicService extends StubMusicPlaybackService {
@override @override
MusicPlaybackStatus get status => track == null ? MusicPlaybackStatus.idle : MusicPlaybackStatus.playing; MusicPlaybackStatus get status => track == null ? MusicPlaybackStatus.idle : MusicPlaybackStatus.playing;
@override
Duration get position => _position;
@override
Stream<Duration> get positionStream => _positionController.stream;
@override @override
Duration? get duration => track == null ? null : const Duration(minutes: 3); Duration? get duration => track == null ? null : const Duration(minutes: 3);
@@ -94,6 +104,17 @@ class _FakeMusicService extends StubMusicPlaybackService {
@override @override
int get currentIndex => track == null ? -1 : 0; 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 @override
Future<void> previous() async { Future<void> previous() async {
previousCalls++; previousCalls++;
@@ -113,6 +134,12 @@ class _FakeMusicService extends StubMusicPlaybackService {
Future<void> stop() async { Future<void> stop() async {
stopCalls++; stopCalls++;
} }
@override
void dispose() {
_positionController.close();
super.dispose();
}
} }
void main() { void main() {
@@ -183,6 +210,37 @@ void main() {
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout) 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 { testWidgets('tapping the card edge opens the named Now Playing route', (tester) async {
final service = _FakeMusicService(track: _track); final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver(); final observer = MusicUiRouteObserver();