mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(player-stats): exclude paused time from listened duration (#942)
* fix(player-stats): exclude paused time from listened duration While paused, the Rust engine stops feeding active progress ticks to the listen session, so the tick baseline (`lastTickMs`) stayed frozen at the pause point. The first progress tick after resume then computed its wall-clock delta against that stale timestamp and billed the entire paused span as listened time, inflating Player stats. Settle the partial segment played up to the pause and mark the session paused; the first resumed progress tick rebaselines instead of counting the gap. Wire the freeze into the single `pause()` transport action. * docs(changelog): note paused-time player-stats fix (#942)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user