fix(player): skip relative to the position a jump landed on

A coalesced key-repeat skip pins its target so a slow backend cannot make
the next press rebase off a position the seek has not reached yet. Nothing
retired that pin when something else moved the playhead, so for the ten
seconds it survived, a skip taken after a timeline tap, a chapter jump, an
OS media control or a peer sync resumed from the superseded target and threw
the user back across their own jump.

Publish every playhead movement on the player and retire the pin whenever
the announced destination is not the accumulator's own commit. Overlapping
seeks and backend-chosen relocations arbitrate by which operation the
backend accepted, so a request that was merely asked for cannot speak for
where the playhead ended up.

close #1819
This commit is contained in:
edde746
2026-08-07 08:43:48 +02:00
parent 660e375248
commit 4816e3928f
21 changed files with 2809 additions and 26 deletions
@@ -46,6 +46,13 @@ abstract class MusicPlaybackService extends ChangeNotifier {
Duration get position;
Stream<Duration> get positionStream;
/// Mirrors `Player.streams.playheadJump`: something is moving the playhead
/// discontinuously, to this position, or to somewhere only the backend knows
/// when null. Request-time intent, not an observed landing.
/// Consumers coalescing their own relative seeks use it to drop a pending
/// target something else superseded (#1819).
Stream<Duration?> get playheadJumpStream => const Stream<Duration?>.empty();
/// Full queue in playback order (shuffle already applied).
List<MediaItem> get queue;
@@ -202,6 +202,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
bool _sleepTimerEndOfTrack = false;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
final StreamController<Duration?> _playheadJumpController = StreamController<Duration?>.broadcast();
final StreamController<Object> _errorsController = StreamController<Object>.broadcast();
// ---------------------------------------------------------------------
@@ -229,6 +230,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
@override
Stream<Duration> get positionStream => _positionController.stream;
@override
Stream<Duration?> get playheadJumpStream => _playheadJumpController.stream;
@override
List<MediaItem> get queue => _queue.queue;
@@ -618,6 +622,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_playerSubs
..clear()
..add(player.streams.position.listen(_onPosition))
..add(player.streams.playheadJump.listen(_playheadJumpController.add))
..add(player.streams.playing.listen(_onPlayingChanged))
..add(player.streams.trackTransition.listen(_onTrackTransition))
..add(player.streams.completed.listen(_onCompleted))
@@ -686,8 +691,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_invalidateArmRequests();
// The finished track played out fully — report stopped at its duration.
// Without one, the player's own record of where the outgoing source got to:
// by now its live position belongs to the track that replaced it.
final finishedMs = _currentTrack?.durationMs;
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
_finalizeCurrentTrack(
positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : _player?.outgoingSourcePosition,
);
// Move the cursor to the armed entry: the expected natural-next when it
// still matches, otherwise wherever the armed track now sits.
@@ -1571,6 +1580,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
// Runs to completion synchronously — see the awaitStop: false contract.
unawaited(_teardownPlayerAndControls(awaitStop: false));
unawaited(_positionController.close());
unawaited(_playheadJumpController.close());
unawaited(_errorsController.close());
_volumeNotifier.dispose();
super.dispose();