feat(now-playing): OpenSubsonic playbackReport for live now-playing (#1080)

* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing

Drive a small playback state machine (starting → playing ↔ paused → stopped)
on the Subsonic-server channel when the server advertises the OpenSubsonic
`playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real
transport state and an extrapolated position. Reports send `ignoreScrobble=true`
so play counts stay on the existing `scrobble.view` 50% path (no double count),
and the effective playback speed is included so the server extrapolates position
correctly with the speed feature on.

- New `playbackReportSession` FSM mirrors the existing `playListenSession`
  lifecycle hooks and is wired at the same player call sites (start / gapless
  switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended /
  error / app quit). Servers without the extension degrade to the unchanged
  legacy `scrobble.view?submission=false` presence call.
- Gated through the existing serverCapabilities framework: a new
  `FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic
  extensions probe now stores the full advertised list once and serves both
  AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing
  the legacy Instant Mix opt-in on pre-0.62 servers.
- Now Playing dropdown shows a live position bar and a paused indicator.
- reportPlayback uses the real request params (mediaId / mediaType / positionMs).

Tests: FSM transitions + gating + legacy fallback, capability resolution,
extension-list probe storage/decoupling. Full suite green; no Tauri-boundary
changes.

* feat(now-playing): glide the Live position bar between polls

Extrapolate the position of `playing` entries locally (elapsed × reported
playbackRate from the last 10 s poll) and re-render once a second, so the Live
progress bar advances smoothly instead of jumping on each refresh. Paused and
position-less entries stay frozen. A linear width transition matched to the tick
keeps the fill gliding. Only applies to clients that report a position via the
playbackReport extension.

* fix(now-playing): tighten Live timer layout and report resume immediately

Keep the progress-bar width stable without a wide empty gap before the
timer: reserve ~2ch inside the current-time span only (right-aligned), not
on the whole clock block. Report `playing` to the server as soon as
resume() runs, matching the immediate `paused` report on pause instead of
waiting for the Rust `audio:playing` event.

* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
This commit is contained in:
cucadmuh
2026-06-13 05:31:26 +03:00
committed by GitHub
parent abc2c0b579
commit 891ab0dd5b
24 changed files with 601 additions and 33 deletions
+21
View File
@@ -22,6 +22,7 @@ function reset() {
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
audiomuseNavidromeIssueByServer: {},
openSubsonicExtensionsByServer: {},
} as never);
}
@@ -57,6 +58,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('stores the full extension list and caches both sonicSimilarity and playbackReport', async () => {
fetchMock.mockResolvedValue(['sonicSimilarity', 'playbackReport']);
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
await flush();
const s = useAuthStore.getState();
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['sonicSimilarity', 'playbackReport']);
expect(s.audiomusePluginProbeByServer[SID]).toBe('present');
});
it('stores the list on a non-Navidrome OpenSubsonic server without driving the AudioMuse probe', async () => {
fetchMock.mockResolvedValue(['playbackReport']);
const idGonic: SubsonicServerIdentity = { type: 'gonic', serverVersion: '0.16.0', openSubsonic: true };
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', idGonic);
expect(fetchMock).toHaveBeenCalledTimes(1);
await flush();
const s = useAuthStore.getState();
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['playbackReport']);
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
});
});
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
+21 -8
View File
@@ -140,16 +140,29 @@ export function scheduleInstantMixProbeForServer(
const store = useAuthStore.getState();
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
// The AudioMuse `sonicSimilarity` lifecycle (with its opt-in side effects) is
// only driven on Navidrome ≥ 0.62, so broadening the probe to all OpenSubsonic
// servers for `playbackReport` does not disturb the legacy Instant Mix opt-in.
const audiomuseEligible = ctx.isNavidrome && ctx.semverGte([0, 62, 0]);
const cached = store.audiomusePluginProbeByServer[serverId];
// Re-probe only without a definitive cached result (or on force / prior error).
// `probing` means an in-flight fetch — skip to avoid a duplicate request.
if (force || cached === undefined || cached === 'error') {
store.setAudiomusePluginProbe(serverId, 'probing');
const listMissing = store.openSubsonicExtensionsByServer[serverId] === undefined;
// Re-probe without a definitive cached result, on force / prior error, or when
// the extension list is missing (self-heal for state persisted before it was
// captured). `probing` means an in-flight fetch — skip to avoid a duplicate.
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
if (force || listMissing || audiomuseStale) {
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
const result = extensions === null
? 'error'
: extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent';
useAuthStore.getState().setAudiomusePluginProbe(serverId, result);
const st = useAuthStore.getState();
if (extensions === null) {
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
return;
}
st.setOpenSubsonicExtensions(serverId, extensions);
if (audiomuseEligible) {
st.setAudiomusePluginProbe(serverId, extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent');
}
});
}
}
+40 -1
View File
@@ -1,5 +1,5 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import type { PlaybackReportState, SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
@@ -39,6 +39,45 @@ export async function reportNowPlaying(id: string, serverId: string): Promise<vo
}
}
export interface ReportPlaybackParams {
mediaId: string;
positionMs: number;
state: PlaybackReportState;
/** Effective playback speed; lets the server extrapolate position correctly. */
playbackRate?: number;
/**
* When true, the server records live presence only and skips its scrobble /
* play-count side effects. psysonic keeps those on the dedicated `scrobble.view`
* channel (50% rule), so the timeline never double-counts a play.
*/
ignoreScrobble?: boolean;
}
/**
* OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62): report a point on
* the playback timeline for rich, live now-playing. Best-effort and gated by the
* same reachability guard as presence scrobbles; callers route through
* `playbackReportSession` which only invokes this when the server advertises the
* extension (otherwise the legacy `reportNowPlaying` presence call is used).
*/
export async function reportPlayback(serverId: string, params: ReportPlaybackParams): Promise<void> {
if (!serverId) return;
if (!shouldAttemptSubsonicForServer(serverId)) return;
const query: Record<string, unknown> = {
mediaId: params.mediaId,
mediaType: 'song',
positionMs: Math.max(0, Math.floor(params.positionMs)),
state: params.state,
};
if (params.playbackRate !== undefined) query.playbackRate = params.playbackRate;
if (params.ignoreScrobble !== undefined) query.ignoreScrobble = params.ignoreScrobble;
try {
await apiForServer(serverId, 'reportPlayback.view', query);
} catch {
// best effort
}
}
export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
try {
const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying | SubsonicNowPlaying[] } }>('getNowPlaying.view', { _t: Date.now() });
+9
View File
@@ -142,11 +142,20 @@ export interface SubsonicPlaylist {
coverArt?: string;
}
/** OpenSubsonic `playbackReport` lifecycle state, per the extension spec. */
export type PlaybackReportState = 'starting' | 'playing' | 'paused' | 'stopped';
export interface SubsonicNowPlaying extends SubsonicSong {
username: string;
minutesAgo: number;
playerId: number;
playerName: string;
/** OpenSubsonic `playbackReport`: live transport state for this stream. */
state?: PlaybackReportState;
/** OpenSubsonic `playbackReport`: server-extrapolated position in milliseconds. */
positionMs?: number;
/** OpenSubsonic `playbackReport`: effective playback speed (1.0 = normal). */
playbackRate?: number;
}
export interface SubsonicArtist {