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:
@@ -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
|
||||
|
||||
@@ -1641,6 +1641,39 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('startTracking overrides only the initial report with the caller-supplied start state (#1849)', () {
|
||||
fakeAsync((async) {
|
||||
final client = _FakePlexClient();
|
||||
// At bind time the player state can still carry the *previous* item's
|
||||
// playhead (gapless music advance) — reporting it as this item's first
|
||||
// sample told the backend playback was already at ~100%.
|
||||
final player = _FakePlayer(position: const Duration(minutes: 7), duration: const Duration(minutes: 7));
|
||||
final tracker = PlaybackProgressTracker(
|
||||
client: client,
|
||||
metadata: _meta(),
|
||||
player: player,
|
||||
isOffline: false,
|
||||
updateInterval: const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
tracker.startTracking(initialPosition: Duration.zero, initialDuration: const Duration(minutes: 3));
|
||||
async.flushMicrotasks();
|
||||
expect(client.updateProgressCalls.single.time, 0);
|
||||
expect(client.updateProgressCalls.single.duration, const Duration(minutes: 3).inMilliseconds);
|
||||
expect(client.markWatchedCalls, isEmpty);
|
||||
|
||||
// The player has since reported the real source state; ticks read live.
|
||||
player.position = const Duration(seconds: 30);
|
||||
player.duration = const Duration(minutes: 3);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
async.flushMicrotasks();
|
||||
expect(client.updateProgressCalls.last.time, 30000);
|
||||
expect(client.updateProgressCalls.last.duration, const Duration(minutes: 3).inMilliseconds);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('coalesces timer ticks while a progress report is in flight', () {
|
||||
fakeAsync((async) {
|
||||
final client = _DelayedProgressClient();
|
||||
|
||||
Reference in New Issue
Block a user