diff --git a/CHANGELOG.md b/CHANGELOG.md index 56981663..ac61c5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -638,6 +638,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Background polls:** Settings → Storage hot-cache poll 15s; cover registry full disk stats every 30s when idle instead of every 1.5s tick. * **Cover art:** restore lazy route prefetch; batch disk peek before ensure so cached WebP warms `diskSrcCache` without flooding invoke slots; yield when viewport ensures are queued. +### Player stats — paused time no longer counts as listening time + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#942](https://github.com/Psychotoxical/psysonic/pull/942)** + +* Pausing a track and resuming later inflated the listening time in **Statistics → Player stats** — the whole paused span was billed as if the track had been playing. +* Root cause: the session's tick baseline froze on pause, so the first progress tick after resume measured against the pre-pause timestamp. It now settles the played segment on pause and rebaselines on resume. + ## [1.46.0] - 2026-05-18 diff --git a/src/store/playListenSession.test.ts b/src/store/playListenSession.test.ts index 9a1f7b5d..d6ac479b 100644 --- a/src/store/playListenSession.test.ts +++ b/src/store/playListenSession.test.ts @@ -7,6 +7,7 @@ import { usePreviewStore } from './previewStore'; import { _resetPlayListenSessionForTest, playListenSessionFinalize, + playListenSessionOnPause, playListenSessionOnProgress, playListenSessionOpen, resolveDurationSecHint, @@ -142,6 +143,40 @@ describe('playListenSession', () => { expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); }); + it('does not bill the paused gap as listened time after resume', async () => { + vi.useFakeTimers(); + const t0 = Date.now(); + vi.setSystemTime(t0); + await playListenSessionOpen(testTrack, 'server-1'); + + // Play for 8 s. + vi.setSystemTime(t0 + 8_000); + await playListenSessionOnProgress(8, false); + + // User pauses; in production no progress ticks reach the session while + // paused, and the engine sits idle for several minutes. + usePlayerStore.setState({ isPlaying: false }); + playListenSessionOnPause(); + vi.setSystemTime(t0 + 8_000 + 300_000); + + // User resumes and listens for 5 more seconds. + usePlayerStore.setState({ isPlaying: true }); + await playListenSessionOnProgress(9, false); + vi.setSystemTime(t0 + 8_000 + 300_000 + 5_000); + await playListenSessionOnProgress(14, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + + const call = vi + .mocked(invoke) + .mock.calls.find(([cmd]) => cmd === 'library_record_play_session'); + expect(call).toBeDefined(); + const listenedSec = (call![1] as { input: { listenedSec: number } }).input.listenedSec; + // ~13 s of real listening (8 + 5), never the ~300 s paused gap. + expect(listenedSec).toBeGreaterThanOrEqual(12); + expect(listenedSec).toBeLessThan(20); + }); + it('skips when library is not ready', async () => { const { libraryIsReady } = await import('../utils/library/libraryReady'); vi.mocked(libraryIsReady).mockResolvedValueOnce(false); diff --git a/src/store/playListenSession.ts b/src/store/playListenSession.ts index 724bed65..0309ed67 100644 --- a/src/store/playListenSession.ts +++ b/src/store/playListenSession.ts @@ -21,6 +21,7 @@ type OpenSession = { durationSecHint: number; lastTickMs: number; recordingEnabled: boolean; + paused: boolean; }; let open: OpenSession | null = null; @@ -84,9 +85,26 @@ export async function playListenSessionOpen( durationSecHint: resolveDurationSecHint(track, engineDurationSec), lastTickMs: Date.now(), recordingEnabled: true, + paused: false, }; } +/** + * Freeze the listening counter when transport pauses. While paused the Rust + * engine stops feeding active progress ticks to the session, so without this + * the next tick after resume would compute its wall-clock delta against the + * pre-pause timestamp and bill the entire paused span as listened time. We + * settle the partial segment played since the last tick, then mark the session + * paused so the first resumed tick rebaselines instead of counting the gap. + */ +export function playListenSessionOnPause(): void { + if (!open?.recordingEnabled || open.paused) return; + const now = Date.now(); + open.listenedSec += Math.max(0, (now - open.lastTickMs) / 1000); + open.lastTickMs = now; + open.paused = true; +} + export async function playListenSessionOnProgress( currentTime: number, buffering: boolean, @@ -104,6 +122,11 @@ export async function playListenSessionOnProgress( return; } const now = Date.now(); + if (open.paused) { + // First tick after resume — rebaseline so the paused gap is not billed. + open.paused = false; + open.lastTickMs = now; + } const deltaSec = Math.max(0, (now - open.lastTickMs) / 1000); open.lastTickMs = now; open.listenedSec += deltaSec; diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index 3324b81f..0c14af35 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { setIsAudioPaused } from './engineState'; import type { PlayerState } from './playerStoreTypes'; import { flushQueueSyncToServer } from './queueSync'; -import { playListenSessionFinalize } from './playListenSession'; +import { playListenSessionFinalize, playListenSessionOnPause } from './playListenSession'; import { pauseRadio, stopRadio } from './radioPlayer'; import { clearAllPlaybackScheduleTimers } from './scheduleTimers'; import { clearSeekDebounce } from './seekDebounce'; @@ -60,6 +60,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick< pause: () => { clearAllPlaybackScheduleTimers(); + playListenSessionOnPause(); if (get().currentRadio) { pauseRadio(); } else {