feat(simkl): report playback progress while it happens

Simkl only heard about an item once playback crossed the media server's
watched threshold, so stopping partway recorded nothing at all: no resumable
position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the
player lifecycle instead, carrying the measured progress. Seeks report
nothing, as Simkl asks.

The terminal stop owns watched state for in-player playback, so real-time
trackers are excluded from the threshold markWatched fan-out and one watch
never produces two writes. Progress is reported as measured — it doubles as
the user's resume position — so when a server threshold configured below
Simkl's own 80% rule would leave the watch unrecorded, the tracker records it
through /sync/history rather than inflating progress. Manual, container,
offline-replay and external-player marks keep using /sync/history. Only
/scrobble/stop accepts a 409, which is the sole action documented to return
one.

Reports go out one at a time because Simkl serialises scrobble writes per
user and fails queued ones with a 400; overflow sheds the oldest non-terminal
report so an episode swap cannot drop the previous item's stop. A playback
session is pinned to the account bound when it began and every send re-checks
that binding, so a profile switch or a disconnect/reconnect can neither
redirect a queued report nor misfile the watched fallback.

Also close the paths that lost the terminal report entirely: app exit flushes
it instead of dropping it, the desktop window button goes through the app
shutdown rather than exit(0), a detached VOD player reports a stop, and a
finished item reports completion at EOF instead of waiting for teardown. A
session that opened at 0% is still closed on stop, or Simkl keeps showing the
item as playing until its runtime elapses.

close #1719
This commit is contained in:
edde746
2026-07-30 11:55:13 +02:00
parent 97cb98526e
commit 5a25c1f9cc
14 changed files with 958 additions and 34 deletions
+13
View File
@@ -927,6 +927,19 @@ class _MainScreenState extends State<MainScreen>
@override
void onWindowClose() {
unawaited(_exitOnWindowClose());
}
/// `setPreventClose(true)` hands the window's close button to us, so the app
/// has to shut itself down. A bare `exit(0)` killed the process before the
/// app-level teardown could run — including the terminal playback report that
/// trackers owning their own watched semantics depend on.
Future<void> _exitOnWindowClose() async {
try {
await AppExitService.requestGracefulExit().timeout(const Duration(seconds: 5));
} catch (e, st) {
appLogger.w('Graceful window close failed; exiting immediately', error: e, stackTrace: st);
}
exit(0);
}
@@ -23,6 +23,16 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
_updateMediaControlsPlaybackState();
unawaited(DiscordRPCService.instance.pausePlayback());
unawaited(TraktScrobbleService.instance.pausePlayback());
// The item finished, so real-time trackers get a terminal report now rather
// than whenever the screen happens to tear down: a completion prompt or
// end-of-video sleep timer can leave it open for minutes, and until then
// the service would still show the item as playing. Seed the known duration
// first — the position stream can stop a beat short of it on EOF. A later
// dispose or in-place reload finds no context and does nothing.
if (duration != null && duration.inMilliseconds > 0) {
TrackerCoordinator.instance.updatePosition(duration);
}
unawaited(TrackerCoordinator.instance.stopPlayback());
if (_autoPipEnabled) {
unawaited(_updateAutoPipState(isPlaying: false));
}
@@ -645,13 +645,15 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
// Update OS media controls playback state
_updateMediaControlsPlaybackState();
// Update Discord Rich Presence + Trakt scrobble
// Update Discord Rich Presence + real-time trackers
if (isPlaying) {
DiscordRPCService.instance.resumePlayback();
TraktScrobbleService.instance.resumePlayback();
TrackerCoordinator.instance.resumePlayback();
} else {
DiscordRPCService.instance.pausePlayback();
TraktScrobbleService.instance.pausePlayback();
TrackerCoordinator.instance.pausePlayback();
}
// Update auto-PiP readiness
+9 -1
View File
@@ -894,7 +894,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
break;
case AppLifecycleState.detached:
_recordLifecycleState('detached');
if (widget.isLive) unawaited(_sendStoppedProgressOnce());
if (widget.isLive) {
unawaited(_sendStoppedProgressOnce());
} else {
// Last chance for VOD: dispose may never run on a terminate, and the
// trackers that own their own watched semantics need the terminal
// report.
unawaited(TrackerCoordinator.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
}
break;
}
}