@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
unawaited(service.seek(Duration(milliseconds: value.round())));
|
if (service.currentTrack?.globalKey == widget.trackKey) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
_errorsController.add(StateError('No server available for instant mix'));
|
if (isPlayIntentCurrent(intent)) {
|
||||||
|
_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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user