@@ -526,6 +526,10 @@ sealed class MediaItem with _$MediaItem {
|
||||
_ => 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
|
||||
/// parent.
|
||||
String? get albumArtistTitle => switch (kind) {
|
||||
|
||||
@@ -82,15 +82,18 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
/// gated on playback availability first so the stub never fetches.
|
||||
Future<void> _playAll({bool shuffle = false}) async {
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle);
|
||||
if (mounted) showErrorSnackBar(context, message);
|
||||
showErrorSnackBar(context, message);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
if (tracks.isEmpty) {
|
||||
showAppSnackBar(context, emptyMessage);
|
||||
return;
|
||||
|
||||
@@ -81,10 +81,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
/// ValueListenableBuilder-wrapped Transform so drag frames never rebuild
|
||||
/// the screen.
|
||||
final ValueNotifier<double> _dismissDrag = ValueNotifier<double>(0);
|
||||
late final AnimationController _dismissSettle = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
)..addListener(_onDismissSettleTick);
|
||||
late final AnimationController _dismissSettle;
|
||||
double _dismissSettleFrom = 0;
|
||||
|
||||
final FocusNode _seekFocusNode = FocusNode(debugLabel: 'now_playing_seek');
|
||||
@@ -95,6 +92,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
|
||||
bool _poppedForIdle = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dismissSettle = AnimationController(vsync: this, duration: const Duration(milliseconds: 200))
|
||||
..addListener(_onDismissSettleTick);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -306,7 +310,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
|
||||
Widget upper = Column(
|
||||
children: [
|
||||
_buildTopBar(track, service.playContext?.title),
|
||||
_buildTopBar(track, service.playContext),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
@@ -332,7 +336,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
return Column(
|
||||
children: [
|
||||
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),
|
||||
_buildUtilityRow(showQueueButton: true),
|
||||
const SizedBox(height: 8),
|
||||
@@ -344,7 +348,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
final tk = tokens(context);
|
||||
return Column(
|
||||
children: [
|
||||
_buildTopBar(track, service.playContext?.title),
|
||||
_buildTopBar(track, service.playContext),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
|
||||
@@ -360,7 +364,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
children: [
|
||||
_buildTrackInfo(track, centered: false),
|
||||
const SizedBox(height: 8),
|
||||
_buildSeekBar(),
|
||||
_buildSeekBar(track),
|
||||
_buildWideControlBand(service),
|
||||
const SizedBox(height: 12),
|
||||
// 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) {
|
||||
final tk = tokens(context);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final playContextTitle = service.playContext?.title;
|
||||
final sourceTitle = _playingFromTitle(track, service.playContext);
|
||||
final artist = track.trackArtistTitle;
|
||||
|
||||
return Padding(
|
||||
@@ -404,10 +408,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: playContextTitle == null || playContextTitle.isEmpty
|
||||
child: sourceTitle == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
t.music.playingFrom(title: playContextTitle),
|
||||
t.music.playingFrom(title: sourceTitle),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 14, color: tk.textMuted),
|
||||
@@ -433,7 +437,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
_buildSeekBar(),
|
||||
_buildSeekBar(track),
|
||||
const SizedBox(height: 8),
|
||||
_buildTransportRow(service),
|
||||
_buildUtilityRow(showQueueButton: true),
|
||||
@@ -449,11 +453,12 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
// 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
|
||||
/// 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 sourceTitle = _playingFromTitle(track, playContext);
|
||||
// macOS pins the traffic lights at y=21 (16pt buttons → center 29, see
|
||||
// WindowUtilsPlugin.customButtonPositions); a fixed 58px row centers the
|
||||
// close button on that line regardless of the platform visual density
|
||||
@@ -479,10 +484,10 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
||||
context: context,
|
||||
)!,
|
||||
Expanded(
|
||||
child: playContextTitle == null || playContextTitle.isEmpty
|
||||
child: sourceTitle == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
t.music.playingFrom(title: playContextTitle),
|
||||
t.music.playingFrom(title: sourceTitle),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
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
|
||||
/// (equal-width flanks), with lyrics + a desktop volume slider as a
|
||||
/// 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(
|
||||
trackKey: track.globalKey,
|
||||
focusNode: _seekFocusNode,
|
||||
onNavigateUp: PlatformDetector.isTV() ? _overflowFocusNode.requestFocus : null,
|
||||
onNavigateDown: _focusTransport,
|
||||
@@ -980,12 +999,14 @@ class _PlayPauseButton extends StatelessWidget {
|
||||
/// timeline's stepped key-repeat acceleration; focus renders as a
|
||||
/// text-based background pill behind the bar.
|
||||
class _NowPlayingSeekBar extends StatefulWidget {
|
||||
final String trackKey;
|
||||
final FocusNode focusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
final VoidCallback onBack;
|
||||
|
||||
const _NowPlayingSeekBar({
|
||||
required this.trackKey,
|
||||
required this.focusNode,
|
||||
required this.onNavigateUp,
|
||||
required this.onNavigateDown,
|
||||
@@ -1012,13 +1033,27 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
|
||||
_keySeek = DebouncedSeekAccumulator(
|
||||
currentPosition: () => context.read<MusicPlaybackService>().position,
|
||||
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: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _NowPlayingSeekBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trackKey == widget.trackKey) return;
|
||||
_keySeek.cancel();
|
||||
_dragValueMs = null;
|
||||
_resetSeekState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keySeek.dispose();
|
||||
@@ -1082,6 +1117,7 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
|
||||
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
final bar = StreamBuilder<Duration>(
|
||||
key: ValueKey(widget.trackKey),
|
||||
stream: service.positionStream,
|
||||
builder: (context, snapshot) {
|
||||
final duration = service.duration ?? Duration.zero;
|
||||
@@ -1120,7 +1156,9 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
|
||||
onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
|
||||
onChangeEnd: hasDuration
|
||||
? (value) {
|
||||
unawaited(service.seek(Duration(milliseconds: value.round())));
|
||||
if (service.currentTrack?.globalKey == widget.trackKey) {
|
||||
unawaited(service.seek(Duration(milliseconds: value.round())));
|
||||
}
|
||||
setState(() => _dragValueMs = null);
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -96,6 +96,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
return;
|
||||
}
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
List<MediaItem> tracks;
|
||||
if (_isPlaylistFullyLoaded) {
|
||||
tracks = items;
|
||||
@@ -103,12 +105,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
try {
|
||||
tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title);
|
||||
if (mounted) showErrorSnackBar(context, message);
|
||||
showErrorSnackBar(context, message);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
}
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ enum MusicRepeatMode { off, all, one }
|
||||
/// Coarse playback state of the music session.
|
||||
enum MusicPlaybackStatus { idle, loading, playing, paused, error }
|
||||
|
||||
/// What kind of container playback was started from — drives the
|
||||
/// "Playing from …" line in the player UI.
|
||||
/// What kind of container playback was started from. The player keeps
|
||||
/// 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 }
|
||||
|
||||
/// Provenance of the current queue (album/artist/playlist/instant mix).
|
||||
@@ -19,7 +20,8 @@ class MusicPlayContext {
|
||||
/// don't).
|
||||
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 MusicPlayContextKind kind;
|
||||
@@ -65,6 +67,18 @@ abstract class MusicPlaybackService extends ChangeNotifier {
|
||||
/// handles recovery (skip / stop) itself.
|
||||
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
|
||||
/// to the first track). [shuffle] shuffles with the start track anchored
|
||||
/// first.
|
||||
@@ -146,6 +160,8 @@ abstract class MusicPlaybackService extends ChangeNotifier {
|
||||
/// null-safe without per-call-site feature checks.
|
||||
class StubMusicPlaybackService extends MusicPlaybackService {
|
||||
final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100);
|
||||
int _playIntentGeneration = 0;
|
||||
int _queueSessionRevision = 0;
|
||||
@override
|
||||
bool get isAvailable => false;
|
||||
|
||||
@@ -182,13 +198,25 @@ class StubMusicPlaybackService extends MusicPlaybackService {
|
||||
@override
|
||||
Stream<Object> get errors => const Stream.empty();
|
||||
|
||||
@override
|
||||
int beginPlayIntent() => ++_playIntentGeneration;
|
||||
|
||||
@override
|
||||
bool isPlayIntentCurrent(int intent) => intent == _playIntentGeneration;
|
||||
|
||||
@override
|
||||
int get queueSessionRevision => _queueSessionRevision;
|
||||
|
||||
@override
|
||||
Future<void> playFromList({
|
||||
required List<MediaItem> tracks,
|
||||
MediaItem? startTrack,
|
||||
required MusicPlayContext playContext,
|
||||
bool shuffle = false,
|
||||
}) async {}
|
||||
}) async {
|
||||
beginPlayIntent();
|
||||
_queueSessionRevision++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playInstantMix(MediaItem seed) async {}
|
||||
@@ -244,7 +272,10 @@ class StubMusicPlaybackService extends MusicPlaybackService {
|
||||
void clearUpcoming() {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
Future<void> stop() async {
|
||||
beginPlayIntent();
|
||||
_queueSessionRevision++;
|
||||
}
|
||||
|
||||
@override
|
||||
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.
|
||||
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;
|
||||
bool _resumeAfterInterruption = false;
|
||||
bool _disposed = false;
|
||||
@@ -208,6 +222,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
@override
|
||||
bool get sleepTimerEndOfTrack => _sleepTimerEndOfTrack;
|
||||
|
||||
@override
|
||||
int beginPlayIntent() => ++_playIntentGeneration;
|
||||
|
||||
@override
|
||||
bool isPlayIntentCurrent(int intent) => !_disposed && intent == _playIntentGeneration;
|
||||
|
||||
@override
|
||||
int get queueSessionRevision => _queueSessionRevision;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Session start
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -219,25 +242,30 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
required MusicPlayContext playContext,
|
||||
bool shuffle = false,
|
||||
}) {
|
||||
beginPlayIntent();
|
||||
return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playInstantMix(MediaItem seed) async {
|
||||
final intent = beginPlayIntent();
|
||||
final client = _clientFor(seed);
|
||||
if (client == null) {
|
||||
_errorsController.add(StateError('No server available for instant mix'));
|
||||
if (isPlayIntentCurrent(intent)) {
|
||||
_errorsController.add(StateError('No server available for instant mix'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
tracks = await client.fetchInstantMix(seed.id);
|
||||
} catch (e, st) {
|
||||
if (!isPlayIntentCurrent(intent)) return;
|
||||
appLogger.w('Instant mix fetch failed for ${seed.id}', error: e, stackTrace: st);
|
||||
_errorsController.add(e);
|
||||
return;
|
||||
}
|
||||
if (_disposed || tracks.isEmpty) return;
|
||||
if (!isPlayIntentCurrent(intent) || tracks.isEmpty) return;
|
||||
await _startQueue(
|
||||
tracks: tracks,
|
||||
playContext: MusicPlayContext(title: seed.displayTitle, kind: MusicPlayContextKind.mix),
|
||||
@@ -252,11 +280,14 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
bool autoplay = true,
|
||||
}) async {
|
||||
if (tracks.isEmpty || _disposed) return;
|
||||
beginPlayIntent();
|
||||
_queueSessionRevision++;
|
||||
// Android 13+: the background playback notification needs
|
||||
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
|
||||
// service run regardless; a denial only hides the notification.
|
||||
unawaited(ensureNotificationPermission());
|
||||
final generation = ++_generation;
|
||||
_invalidateArmRequests();
|
||||
_finalizeCurrentTrack();
|
||||
var startIndex = 0;
|
||||
if (startTrack != null) {
|
||||
@@ -333,13 +364,14 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
|
||||
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
|
||||
_bindTrackServices(track, source);
|
||||
unawaited(_armNext(generation));
|
||||
_requestArmNext();
|
||||
}
|
||||
|
||||
/// Manual advance: finalize the current tracker at its current position and
|
||||
/// open the queue entry at [cursor].
|
||||
Future<void> _advanceTo(int cursor, {bool play = true}) async {
|
||||
final generation = ++_generation;
|
||||
_invalidateArmRequests();
|
||||
_finalizeCurrentTrack();
|
||||
_queue.jumpTo(cursor);
|
||||
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
|
||||
/// resolve round-trip when the desired target is already armed; repeat-one
|
||||
/// reuses the current track's resolved source.
|
||||
Future<void> _armNext(int generation) async {
|
||||
Future<void> _applyArmNext(int generation, int armRequest) async {
|
||||
final player = _player;
|
||||
if (player == null || generation != _generation) return;
|
||||
if (player == null || !_isCurrentArmRequest(player, generation, armRequest)) return;
|
||||
|
||||
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
|
||||
final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
|
||||
@@ -366,7 +398,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
|
||||
_rememberStaleArm();
|
||||
await _trySetNext(player, null);
|
||||
if (generation != _generation || _player != player) return;
|
||||
if (!_isCurrentArmRequest(player, generation, armRequest)) return;
|
||||
|
||||
MusicSource source;
|
||||
if (targetCursor == _queue.cursor && _currentSource != null) {
|
||||
@@ -382,11 +414,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (generation != _generation || _player != player) return;
|
||||
_armed = _ArmedTrack(track: target, source: source);
|
||||
if (!_isCurrentArmRequest(player, generation, armRequest)) return;
|
||||
final armed = _ArmedTrack(track: target, source: source);
|
||||
_armed = armed;
|
||||
appLogger.d('Music: arming cursor $targetCursor "${target.title}"');
|
||||
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
|
||||
// completed fallback can advance explicitly instead of waiting for a
|
||||
// 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
|
||||
/// [_onTrackTransition] can adopt a transition that raced the clear.
|
||||
void _rememberStaleArm() {
|
||||
@@ -418,11 +490,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
/// edits that keep the same next track cost no server round-trip.
|
||||
void _rearmIfNeeded() {
|
||||
if (_player == null || _currentTrack == null) return;
|
||||
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
|
||||
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));
|
||||
_requestArmNext();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -501,7 +569,8 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
}
|
||||
final adopted = armed;
|
||||
_armed = null;
|
||||
final generation = ++_generation;
|
||||
_generation++;
|
||||
_invalidateArmRequests();
|
||||
|
||||
// The finished track played out fully — report stopped at its duration.
|
||||
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}');
|
||||
_setStatus(MusicPlaybackStatus.playing, forceNotify: true);
|
||||
_bindTrackServices(_currentTrack!, adopted.source);
|
||||
unawaited(_armNext(generation));
|
||||
_requestArmNext();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
void _parkAtEnd() {
|
||||
_generation++;
|
||||
_invalidateArmRequests();
|
||||
final finishedMs = _currentTrack?.durationMs;
|
||||
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
|
||||
_setStatus(MusicPlaybackStatus.paused, forceNotify: true);
|
||||
@@ -778,28 +848,37 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
Future<void> play() async {
|
||||
final player = _player;
|
||||
if (player == null || _currentTrack == null) return;
|
||||
final generation = _generation;
|
||||
if (player.state.completed) {
|
||||
// Parked at queue end: restart the current track.
|
||||
await player.seek(Duration.zero);
|
||||
if (!_isCurrentTransport(player, generation)) return;
|
||||
final currentTrack = _currentTrack;
|
||||
final currentSource = _currentSource;
|
||||
if (currentTrack != null && currentSource != null) {
|
||||
_bindTrackServices(currentTrack, currentSource);
|
||||
}
|
||||
unawaited(_armNext(_generation));
|
||||
_requestArmNext();
|
||||
}
|
||||
await player.play();
|
||||
if (!_isCurrentTransport(player, generation)) return;
|
||||
_setStatus(MusicPlaybackStatus.playing);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
final player = _player;
|
||||
if (player == null) return;
|
||||
if (player == null || _currentTrack == null) return;
|
||||
final generation = _generation;
|
||||
await player.pause();
|
||||
if (!_isCurrentTransport(player, generation)) return;
|
||||
_setStatus(MusicPlaybackStatus.paused);
|
||||
}
|
||||
|
||||
bool _isCurrentTransport(Player player, int generation) {
|
||||
return !_disposed && _currentTrack != null && generation == _generation && _player == player;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> togglePlayPause() {
|
||||
final player = _player;
|
||||
@@ -1031,7 +1110,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
// End-of-track mode suppresses gapless arming (and leaving it restores
|
||||
// the arm), so the track genuinely completes instead of transitioning.
|
||||
if (hadEndOfTrack != _sleepTimerEndOfTrack) {
|
||||
unawaited(_armNext(_generation));
|
||||
_requestArmNext();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1064,7 +1143,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
Future<void> _stopForVideoClaim() => _stopSession(endStatus: MusicPlaybackStatus.idle);
|
||||
|
||||
Future<void> _stopSession({required MusicPlaybackStatus endStatus}) async {
|
||||
beginPlayIntent();
|
||||
_queueSessionRevision++;
|
||||
_generation++;
|
||||
_invalidateArmRequests();
|
||||
_completedConfirmTimer?.cancel();
|
||||
_completedConfirmTimer = null;
|
||||
_cancelSleepTimer();
|
||||
@@ -1076,6 +1158,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
_staleArm = null;
|
||||
_playContext = null;
|
||||
_resumeAfterInterruption = false;
|
||||
_setStatus(endStatus, forceNotify: true);
|
||||
|
||||
final player = _player;
|
||||
_player = null;
|
||||
@@ -1110,8 +1193,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
unawaited(controls.clear());
|
||||
controls.dispose();
|
||||
}
|
||||
|
||||
_setStatus(endStatus, forceNotify: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1137,6 +1218,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_playIntentGeneration++;
|
||||
_queueSessionRevision++;
|
||||
_generation++;
|
||||
_invalidateArmRequests();
|
||||
_disposed = true;
|
||||
if (_observesLifecycle) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
@@ -564,6 +564,8 @@ class PlexMetadataDto {
|
||||
final double? userRating;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? year;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? parentYear;
|
||||
final String? originallyAvailableAt;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
@@ -666,6 +668,7 @@ class PlexMetadataDto {
|
||||
this.audienceRating,
|
||||
this.userRating,
|
||||
this.year,
|
||||
this.parentYear,
|
||||
this.originallyAvailableAt,
|
||||
this.thumb,
|
||||
this.art,
|
||||
@@ -793,6 +796,7 @@ class PlexMetadataDto {
|
||||
double? audienceRating,
|
||||
double? userRating,
|
||||
int? year,
|
||||
int? parentYear,
|
||||
String? originallyAvailableAt,
|
||||
String? thumb,
|
||||
String? art,
|
||||
@@ -862,6 +866,7 @@ class PlexMetadataDto {
|
||||
audienceRating: audienceRating ?? this.audienceRating,
|
||||
userRating: userRating ?? this.userRating,
|
||||
year: year ?? this.year,
|
||||
parentYear: parentYear ?? this.parentYear,
|
||||
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
|
||||
thumb: thumb ?? this.thumb,
|
||||
art: art ?? this.art,
|
||||
@@ -961,9 +966,10 @@ class PlexMappers {
|
||||
|
||||
/// Map a parsed [PlexMetadataDto] into a [PlexMediaItem].
|
||||
static PlexMediaItem mediaItem(PlexMetadataDto dto) {
|
||||
final kind = MediaKind.fromString(dto.type);
|
||||
return PlexMediaItem(
|
||||
id: dto.ratingKey,
|
||||
kind: MediaKind.fromString(dto.type),
|
||||
kind: kind,
|
||||
guid: dto.guid,
|
||||
title: dto.title,
|
||||
titleSort: dto.titleSort,
|
||||
@@ -972,7 +978,10 @@ class PlexMappers {
|
||||
originalTitle: dto.originalTitle,
|
||||
editionTitle: dto.editionTitle,
|
||||
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,
|
||||
contentRating: dto.contentRating,
|
||||
parentId: dto.parentRatingKey,
|
||||
|
||||
@@ -93,6 +93,7 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
|
||||
audienceRating: flexibleDouble(json['audienceRating']),
|
||||
userRating: flexibleDouble(json['userRating']),
|
||||
year: flexibleInt(json['year']),
|
||||
parentYear: flexibleInt(json['parentYear']),
|
||||
originallyAvailableAt: json['originallyAvailableAt'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
@@ -167,6 +168,7 @@ Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
|
||||
'audienceRating': ?instance.audienceRating,
|
||||
'userRating': ?instance.userRating,
|
||||
'year': ?instance.year,
|
||||
'parentYear': ?instance.parentYear,
|
||||
'originallyAvailableAt': ?instance.originallyAvailableAt,
|
||||
'thumb': ?instance.thumb,
|
||||
'art': ?instance.art,
|
||||
|
||||
@@ -123,6 +123,8 @@ Future<void> playTracks(
|
||||
/// album, isn't found in it, or the album fetch fails.
|
||||
Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) async {
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
|
||||
final albumId = track.parentId;
|
||||
final client = context.getMediaClientForItemOrNull(track);
|
||||
@@ -130,7 +132,7 @@ Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) as
|
||||
try {
|
||||
final tracks = await client.fetchAlbumTracks(albumId);
|
||||
final startIndex = tracks.indexWhere((item) => item.id == track.id);
|
||||
if (!context.mounted) return;
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
if (startIndex != -1) {
|
||||
await playTracks(
|
||||
context,
|
||||
@@ -141,11 +143,13 @@ Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) as
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!service.isPlayIntentCurrent(intent)) return;
|
||||
appLogger.w('Failed to fetch album context for track ${track.id}; playing single track', error: e);
|
||||
if (!context.mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
await playTracks(
|
||||
context,
|
||||
tracks: [track],
|
||||
|
||||
@@ -1062,8 +1062,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
// Availability gate before the container fetch so the stub costs no
|
||||
// server round-trip.
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final tracks = await _musicTracksForItem(item);
|
||||
if (!context.mounted) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
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(
|
||||
context,
|
||||
tracks: tracks,
|
||||
@@ -1079,8 +1087,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final service = context.read<MusicPlaybackService?>();
|
||||
// Menu entries are hidden on the stub; defensive re-check.
|
||||
if (service == null || !service.isAvailable) return;
|
||||
final tracks = await _musicTracksForItem(_mediaItem!);
|
||||
if (tracks.isEmpty) return;
|
||||
final queueSessionRevision = service.queueSessionRevision;
|
||||
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) {
|
||||
service.addNext(tracks);
|
||||
} else {
|
||||
@@ -1383,18 +1398,19 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
// Match PlaylistDetailScreen: fail the availability gate before paying
|
||||
// for a full playlist fetch, then hand the tracks to the music session.
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id);
|
||||
} catch (e, st) {
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
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;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
if (tracks.isEmpty) {
|
||||
showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems);
|
||||
return;
|
||||
|
||||
@@ -286,7 +286,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
||||
height: _MusicMiniPlayerOverlayState._cardHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: _MiniPlayerProgress()),
|
||||
Positioned.fill(child: _MiniPlayerProgress(key: ValueKey(widget.track.globalKey))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
@@ -409,7 +409,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
||||
/// 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.
|
||||
class _MiniPlayerProgress extends StatelessWidget {
|
||||
const _MiniPlayerProgress();
|
||||
const _MiniPlayerProgress({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -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