fix(music): report a gaplessly advanced track's first timeline at its own start

When a gapless advance was announced, the new track's tracker sent its
initial report from live player state, which still carried the finished
track's position and duration - telling Plex the new track was already
at ~100%. PMS recorded a play (and a Last.fm scrobble) at track start on
top of the one from the real playthrough, and the tracker latched the
new track watched locally the moment it began.

The music bind now pins the initial report to the track's own start
(position zero, metadata duration); timer ticks keep reading live state.

close #1849
This commit is contained in:
edde746
2026-08-10 08:33:11 +02:00
parent f1d4be70e2
commit f5665df43f
4 changed files with 104 additions and 10 deletions
@@ -131,12 +131,23 @@ class FakePlayer implements Player {
return gate;
}
/// Emits a gapless advance. Deliberately leaves `state.position`/`duration`
/// untouched: the real player announces the transition on the event flow,
/// which is not ordered against the property flow — at that instant the live
/// state can still carry the *finished* track's playhead (#1849). Tests
/// model that handover with [setOutgoingPlayhead] before emitting.
void emitTransition(String uri) {
_armedMedia = null; // the backend advanced into the armed entry
_state = _state.copyWith(completed: false, position: Duration.zero, duration: _trackDuration);
_state = _state.copyWith(completed: false);
trackTransitionCtrl.add(uri);
}
/// Backdates the live state to the finished track's playhead, as the real
/// player still reads when a gapless transition is announced.
void setOutgoingPlayhead({required Duration position, required Duration duration}) {
_state = _state.copyWith(position: position, duration: duration);
}
void emitCompleted() {
_state = _state.copyWith(completed: true, position: _trackDuration);
completedCtrl.add(true);
@@ -394,11 +405,12 @@ class RecordedReport {
final String state;
final String itemId;
final Duration position;
final Duration? duration;
const RecordedReport(this.state, this.itemId, this.position);
const RecordedReport(this.state, this.itemId, this.position, this.duration);
@override
String toString() => '$state($itemId @ ${position.inSeconds}s)';
String toString() => '$state($itemId @ ${position.inSeconds}s/${duration?.inSeconds}s)';
}
/// Records the progress-report surface; everything else is unimplemented
@@ -439,7 +451,7 @@ class FakeMediaServerClient extends Fake with PlaybackReportRecorder implements
PlaybackReportKind.progress => call.isPaused ? 'paused' : 'progress',
PlaybackReportKind.stopped => 'stopped',
};
reports.add(RecordedReport(state, call.itemId, call.position));
reports.add(RecordedReport(state, call.itemId, call.position, call.duration));
}
}
@@ -976,6 +988,37 @@ void main() {
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1', 't2']);
});
test('a gaplessly advanced track reports its own start, not the finished track\'s playhead (#1849)', () async {
final long = testMediaItem(
id: 'long',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Long opener',
parentTitle: 'Album',
grandparentTitle: 'Artist',
durationMs: const Duration(minutes: 7).inMilliseconds,
serverId: 'srv',
);
await h.playTracks([long, t2]);
// When the advance is announced, the live player state still carries the
// finished track's playhead — the new file has not reported yet. Reporting
// that as the new track's first sample told Plex it was already at ~100%,
// which recorded a play (and a Last.fm scrobble) at track start on top of
// the real one (#1849).
h.player.setOutgoingPlayhead(position: const Duration(minutes: 7), duration: const Duration(minutes: 7));
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
final started = h.client.reportsFor('started').toList();
expect(started.map((r) => r.itemId), ['long', 't2']);
expect(started.last.position, Duration.zero);
expect(started.last.duration, _trackDuration, reason: 'the initial report carries t2\'s own duration');
// The finished track is the only one whose watch settles; t2 must not be
// latched watched off the stale ~100% sample.
expect(h.client.markedWatched, ['long']);
});
test('a track with no metadata duration is reported stopped where the source actually got to', () async {
// Nothing supplies a duration to report at, so the outgoing position is the
// only truth — and by the time the transition is handled the player's live