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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user