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
+10
View File
@@ -120,6 +120,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Live — rich now-playing on Navidrome 0.62+
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1080](https://github.com/Psychotoxical/psysonic/pull/1080)**
* On servers that advertise the OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62), Psysonic reports live transport state and position so **Live** shows who is playing or paused and where in the track — including playback speed when the other client sends it.
* The position bar glides between refreshes; pause and resume update the server immediately instead of waiting for the audio engine.
* Play counts are unchanged — still driven by the existing scrobble path. Servers without the extension keep the previous now-playing behaviour.
## Changed
### Settings → Servers — compact server cards
+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 {
+66 -1
View File
@@ -4,7 +4,7 @@ import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { PlayCircle, User, Clock, Radio, RefreshCw } from 'lucide-react';
import { PlayCircle, Pause, User, Clock, Radio, RefreshCw } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -22,8 +22,33 @@ export default function NowPlayingDropdown() {
const triggerWrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
// Wall-clock baseline for the last poll: between polls (every 10 s) we
// extrapolate the position of `playing` entries locally so the progress bar
// glides instead of snapping. The server already extrapolates positionMs at
// fetch time, so this just continues from there using the reported speed.
const fetchedAtRef = useRef(0);
const [, forceTick] = useState(0);
const PANEL_WIDTH = 340;
const formatClock = (totalSec: number) => {
const s = Math.max(0, Math.floor(totalSec));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
};
// Live position in seconds: advance `playing` entries by elapsed × playbackRate
// since the last poll; freeze everything else at the reported position.
const livePositionSec = (entry: SubsonicNowPlaying): number | undefined => {
if (typeof entry.positionMs !== 'number') return undefined;
let ms = entry.positionMs;
if (entry.state === 'playing') {
const rate = entry.playbackRate && entry.playbackRate > 0 ? entry.playbackRate : 1;
ms += (Date.now() - fetchedAtRef.current) * rate;
}
const maxMs = entry.duration > 0 ? entry.duration * 1000 : ms;
return Math.min(ms, maxMs) / 1000;
};
const updatePanelPos = useCallback(() => {
const el = triggerWrapRef.current;
if (!el) return;
@@ -39,6 +64,7 @@ export default function NowPlayingDropdown() {
setLoading(true);
try {
const data = await getNowPlaying();
fetchedAtRef.current = Date.now();
setNowPlaying(data);
} catch (e) {
console.error('Failed to load Now Playing', e);
@@ -64,6 +90,17 @@ export default function NowPlayingDropdown() {
return () => clearInterval(id);
}, [isOpen]);
// Re-render once per second while a `playing` entry exposes a position, so the
// locally-extrapolated bar advances smoothly between the 10 s polls.
const hasLivePosition = nowPlaying.some(
e => e.state === 'playing' && typeof e.positionMs === 'number',
);
useEffect(() => {
if (!isOpen || !hasLivePosition) return;
const id = setInterval(() => forceTick(v => v + 1), 1000);
return () => clearInterval(id);
}, [isOpen, hasLivePosition]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePanelPos();
@@ -193,6 +230,34 @@ export default function NowPlayingDropdown() {
<span className="truncate">{t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>
</div>
)}
{(() => {
const posSec = livePositionSec(stream);
if (posSec === undefined || stream.duration <= 0) return null;
const playing = stream.state === 'playing';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, marginTop: '1px' }}>
{stream.state === 'paused' && <Pause size={10} style={{ flexShrink: 0 }} />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ height: '3px', borderRadius: '2px', background: 'var(--border-subtle)', overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, (posSec / stream.duration) * 100))}%`,
height: '100%',
background: playing ? 'var(--accent)' : 'var(--text-muted)',
transition: playing ? 'width 1s linear' : 'none',
}} />
</div>
</div>
<span style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>
{/* ~2ch reserve inside the current-time box (9:59→10:00), not empty gap before the bar. */}
<span style={{ display: 'inline-block', minWidth: '6ch', textAlign: 'right' }}>
{formatClock(posSec)}
</span>
{' / '}
{formatClock(stream.duration)}
</span>
</div>
);
})()}
</div>
</div>
</div>
+1
View File
@@ -163,6 +163,7 @@ const CONTRIBUTOR_ENTRIES = [
'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)',
'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)',
'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)',
'OpenSubsonic playbackReport — live now-playing state, gliding position bar, and immediate pause/resume on Navidrome ≥0.62 (PR #1080)',
],
},
{
@@ -5,6 +5,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import type { NavigateFunction } from 'react-router-dom';
import { flushPlayQueuePosition } from '../../store/queueSync';
import { playListenSessionFinalize } from '../../store/playListenSession';
import { playbackReportStopped } from '../../store/playbackReportSession';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
@@ -112,6 +113,11 @@ export function useMediaAndWindowBridge(navigate: NavigateFunction) {
playListenSessionFinalize('close'),
new Promise(r => setTimeout(r, 1500)),
]);
// Drop our live now-playing entry on quit (playbackReport extension).
await Promise.race([
playbackReportStopped(),
new Promise(r => setTimeout(r, 1500)),
]);
await Promise.race([
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
+29
View File
@@ -13,14 +13,17 @@ import type { CapabilityDefinition } from './types';
*/
export const SONIC_SIMILARITY_EXTENSION = 'sonicSimilarity';
export const PLAYBACK_REPORT_EXTENSION = 'playbackReport';
export const FEATURE_AUDIOMUSE_SIMILAR_TRACKS = 'audiomuse.similarTracks';
export const FEATURE_PLAYBACK_REPORT = 'opensubsonic.playbackReport';
export const PROBE_OPENSUBSONIC_EXTENSIONS = 'opensubsonic.extensions';
export const PROBE_LEGACY_INSTANT_MIX = 'navidrome.instantMix.legacy';
/** Operation names used by the call router. */
export const OP_SIMILAR_TRACKS = 'similarTracks';
export const OP_REPORT_PLAYBACK = 'reportPlayback';
export const SERVER_CAPABILITY_CATALOG: CapabilityDefinition[] = [
{
@@ -65,6 +68,32 @@ export const SERVER_CAPABILITY_CATALOG: CapabilityDefinition[] = [
},
],
},
{
// OpenSubsonic `playbackReport` (Navidrome ≥ 0.62): rich live now-playing via
// a small playback FSM. Auto-on wherever the server advertises the extension;
// call sites fall back to legacy `scrobble.view?submission=false` presence when
// it is absent. Detection is server-agnostic (any OpenSubsonic server may add it).
feature: FEATURE_PLAYBACK_REPORT,
labelKey: 'nowPlaying.title',
strategies: [
{
id: 'opensubsonic.playbackReport',
priority: 100,
when: (ctx) => ctx.openSubsonic,
detection: {
kind: 'extension',
probeId: PROBE_OPENSUBSONIC_EXTENSIONS,
extension: PLAYBACK_REPORT_EXTENSION,
},
trust: 'high',
activation: 'auto',
calls: {
[OP_REPORT_PLAYBACK]: { endpoint: 'reportPlayback.view', transport: 'opensubsonic' },
},
labelKey: 'nowPlaying.title',
},
],
},
];
export function getCapabilityDefinition(feature: string): CapabilityDefinition | undefined {
+34 -2
View File
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import { buildCapabilityContext } from './context';
import {
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
FEATURE_PLAYBACK_REPORT,
OP_SIMILAR_TRACKS,
PLAYBACK_REPORT_EXTENSION,
PROBE_LEGACY_INSTANT_MIX,
PROBE_OPENSUBSONIC_EXTENSIONS,
SERVER_CAPABILITY_CATALOG,
@@ -50,10 +52,40 @@ describe('neededProbeIds', () => {
expect(ids.has(PROBE_OPENSUBSONIC_EXTENSIONS)).toBe(true);
expect(ids.has(PROBE_LEGACY_INSTANT_MIX)).toBe(false);
});
it('asks for the legacy probe on 0.61', () => {
it('asks for the legacy probe on 0.61, plus the extensions probe for playbackReport', () => {
const ids = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctxFor('0.61.0'));
expect(ids.has(PROBE_LEGACY_INSTANT_MIX)).toBe(true);
expect(ids.has(PROBE_OPENSUBSONIC_EXTENSIONS)).toBe(false);
// playbackReport detection is OpenSubsonic-generic, so the extensions probe
// is now needed on any OpenSubsonic server (the fetch is shared).
expect(ids.has(PROBE_OPENSUBSONIC_EXTENSIONS)).toBe(true);
});
});
describe('playbackReport capability', () => {
const pbDef = getCapabilityDefinition(FEATURE_PLAYBACK_REPORT)!;
const withExt: ProbeOutcome = { status: 'present', extensions: [PLAYBACK_REPORT_EXTENSION] };
const withoutExt: ProbeOutcome = { status: 'present', extensions: [SONIC_SIMILARITY_EXTENSION] };
it('is auto-active on any OpenSubsonic server that advertises the extension', () => {
const r = resolveCapability(pbDef, ctxFor('0.62.0'), { [PROBE_OPENSUBSONIC_EXTENSIONS]: withExt });
expect(r).toMatchObject({ status: 'present', activation: 'auto', trust: 'high' });
expect(isCapabilityActive(r, false)).toBe(true);
});
it('detects on non-Navidrome OpenSubsonic servers too', () => {
const r = resolveCapability(pbDef, ctxFor('1.16.1', 'gonic'), { [PROBE_OPENSUBSONIC_EXTENSIONS]: withExt });
expect(r.status).toBe('present');
});
it('is absent when the extension is not advertised', () => {
const r = resolveCapability(pbDef, ctxFor('0.62.0'), { [PROBE_OPENSUBSONIC_EXTENSIONS]: withoutExt });
expect(r.status).toBe('absent');
expect(isCapabilityActive(r, true)).toBe(false);
});
it('is ineligible on non-OpenSubsonic servers', () => {
const r = resolveCapability(pbDef, buildCapabilityContext({ type: 'subsonic', serverVersion: '1.16.1', openSubsonic: false }), {});
expect(r.status).toBe('ineligible');
});
});
+23 -1
View File
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '../store/authStore';
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS } from './catalog';
import {
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
FEATURE_PLAYBACK_REPORT,
OP_SIMILAR_TRACKS,
} from './catalog';
import {
isFeatureActiveForServer,
resolveCallRoutesForServer,
@@ -15,6 +19,7 @@ function seed(identity: Record<string, unknown>, extra: Record<string, unknown>
audiomusePluginProbeByServer: {},
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
openSubsonicExtensionsByServer: {},
...extra,
} as never);
}
@@ -26,6 +31,7 @@ describe('storeView (capability read facade)', () => {
audiomusePluginProbeByServer: {},
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
openSubsonicExtensionsByServer: {},
} as never);
});
@@ -64,4 +70,20 @@ describe('storeView (capability read facade)', () => {
expect(resolveFeatureForServer(SID, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)?.status).toBe('ineligible');
expect(resolveCallRoutesForServer(SID, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS)).toEqual([]);
});
it('playbackReport is active from the stored extension list', () => {
seed({ type: 'navidrome', serverVersion: '0.62.1', openSubsonic: true }, {
openSubsonicExtensionsByServer: { [SID]: ['sonicSimilarity', 'playbackReport'] },
});
expect(isFeatureActiveForServer(SID, FEATURE_PLAYBACK_REPORT)).toBe(true);
// The same stored list still satisfies AudioMuse detection.
expect(isFeatureActiveForServer(SID, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)).toBe(true);
});
it('playbackReport is inactive when the extension is absent from the list', () => {
seed({ type: 'navidrome', serverVersion: '0.62.1', openSubsonic: true }, {
openSubsonicExtensionsByServer: { [SID]: ['sonicSimilarity'] },
});
expect(isFeatureActiveForServer(SID, FEATURE_PLAYBACK_REPORT)).toBe(false);
});
});
+12 -3
View File
@@ -1,6 +1,5 @@
import { useAuthStore } from '../store/authStore';
import type {
AudiomusePluginProbeResult,
InstantMixProbeResult,
} from '../utils/server/subsonicServerIdentity';
import { buildCapabilityContext } from './context';
@@ -25,8 +24,18 @@ import type {
* Probe results currently live in dedicated per-server store maps. This facade
* maps them into the generic `ProbeOutcome` shape the resolver consumes, so the
* catalog/resolver/router stay storage-agnostic.
*
* The OpenSubsonic extensions outcome is built from the full advertised list
* (`openSubsonicExtensionsByServer`) so every extension-gated feature reads from
* one source. The AudioMuse `sonicSimilarity` probe lifecycle still supplies the
* probing/error transitions (it drives the fetch on Navidrome ≥ 0.62), and acts
* as a back-compat fallback for state persisted before the list was captured.
*/
function pluginProbeToOutcome(probe: AudiomusePluginProbeResult | undefined): ProbeOutcome | undefined {
function openSubsonicExtensionsOutcome(serverId: string): ProbeOutcome | undefined {
const s = useAuthStore.getState();
const list = s.openSubsonicExtensionsByServer[serverId];
if (list) return { status: 'present', extensions: list };
const probe = s.audiomusePluginProbeByServer[serverId];
switch (probe) {
case 'present': return { status: 'present', extensions: [SONIC_SIMILARITY_EXTENSION] };
case 'absent': return { status: 'present', extensions: [] };
@@ -49,7 +58,7 @@ function legacyProbeToOutcome(probe: InstantMixProbeResult | undefined): ProbeOu
export function buildProbeOutcomesForServer(serverId: string): Record<string, ProbeOutcome | undefined> {
const s = useAuthStore.getState();
return {
[PROBE_OPENSUBSONIC_EXTENSIONS]: pluginProbeToOutcome(s.audiomusePluginProbeByServer[serverId]),
[PROBE_OPENSUBSONIC_EXTENSIONS]: openSubsonicExtensionsOutcome(serverId),
[PROBE_LEGACY_INSTANT_MIX]: legacyProbeToOutcome(s.instantMixProbeByServer[serverId]),
};
}
+2 -4
View File
@@ -1,8 +1,7 @@
import { reportNowPlaying } from '../api/subsonicScrobble';
import { playbackReportStart } from './playbackReportSession';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
import { useAuthStore } from './authStore';
import {
bumpPlayGeneration,
getPlayGeneration,
@@ -222,8 +221,7 @@ export function applyQueueHistorySnapshot(
get().updateReplayGainForCurrentTrack();
if (!keepPlaybackFromPrior) {
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
if (npUndo) reportNowPlaying(nextTrack.id, getPlaybackServerId());
playbackReportStart(nextTrack.id, getPlaybackServerId());
queueUndoRestoreAudioEngine({
generation: gen,
+22 -8
View File
@@ -1,5 +1,10 @@
import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
import { scrobbleSong } from '../api/subsonicScrobble';
import type { Track } from './playerStoreTypes';
import {
playbackReportPlaying,
playbackReportStart,
playbackReportStopped,
} from './playbackReportSession';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '../music-network';
@@ -97,6 +102,9 @@ export function handleAudioPlaying(duration: number): void {
if (track) {
const ref = queueItems[queueIndex];
void playListenSessionOpen(track, playbackProfileIdForTrack(track, ref), duration);
// Engine-confirmed play (initial start + resume) — keep live now-playing in
// the `playing` state for servers with the playbackReport extension.
playbackReportPlaying();
}
}
@@ -184,6 +192,9 @@ export function handleAudioProgress(
const now = Date.now();
if (now - getLastQueueHeartbeatAt() >= 15_000) {
void flushQueueSyncToServer(store.queueItems, track, displayTime);
// Same 15 s cadence keeps the server's now-playing position fresh so it
// can extrapolate accurately between reports (playbackReport extension).
playbackReportPlaying(displayTime);
}
}
@@ -342,6 +353,9 @@ export function handleAudioEnded(): void {
}
void playListenSessionFinalize('ended');
// Track finished — clear live now-playing. A follow-on track (next / repeat)
// opens a fresh session via playbackReportStart.
void playbackReportStopped();
// Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) {
@@ -460,13 +474,12 @@ export function handleAudioTrackSwitched(_duration: number): void {
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
// Navidrome now-playing follows nowPlayingEnabled; Music Network now-playing
// follows scrobbling, as Last.fm now-playing did (the runtime gates on the
// master toggle, per-account enable and the nowPlaying capability internally).
const { nowPlayingEnabled } = useAuthStore.getState();
if (nowPlayingEnabled) {
reportNowPlaying(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
}
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
// on the master toggle, per-account enable and the nowPlaying capability
// internally). playbackReportStart opens the FSM on extension-capable servers
// and falls back to the legacy presence call otherwise (gating is internal).
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: nextTrack.title,
@@ -489,6 +502,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
export function handleAudioError(message: string): void {
console.error('[psysonic] Audio error from backend:', message);
setIsAudioPaused(false);
void playbackReportStopped();
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
@@ -28,6 +28,7 @@ export function createPerServerCapabilityActions(set: SetState): Pick<
| 'setSubsonicServerIdentity'
| 'setInstantMixProbe'
| 'setAudiomusePluginProbe'
| 'setOpenSubsonicExtensions'
| 'setAudiomuseNavidromeIssue'
> {
return {
@@ -71,10 +72,12 @@ export function createPerServerCapabilityActions(set: SetState): Pick<
if (prev && (prev.serverVersion !== identity.serverVersion || prev.type !== identity.type)) {
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
const { [serverId]: _pp, ...pluginProbeRest } = s.audiomusePluginProbeByServer;
const { [serverId]: _ex, ...extRest } = s.openSubsonicExtensionsByServer;
return {
subsonicServerIdentityByServer,
instantMixProbeByServer: probeRest,
audiomusePluginProbeByServer: pluginProbeRest,
openSubsonicExtensionsByServer: extRest,
};
}
return { subsonicServerIdentityByServer };
@@ -116,6 +119,11 @@ export function createPerServerCapabilityActions(set: SetState): Pick<
return { audiomusePluginProbeByServer };
}),
setOpenSubsonicExtensions: (serverId, extensions) =>
set(s => ({
openSubsonicExtensionsByServer: { ...s.openSubsonicExtensionsByServer, [serverId]: extensions },
})),
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
set(s =>
hasIssue
+2
View File
@@ -57,6 +57,7 @@ export function createServerProfileActions(set: SetState): Pick<
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
const { [id]: _ppl, ...pluginProbeRest } = s.audiomusePluginProbeByServer;
const { [id]: _ex, ...extRest } = s.openSubsonicExtensionsByServer;
return {
servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
@@ -67,6 +68,7 @@ export function createServerProfileActions(set: SetState): Pick<
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
audiomusePluginProbeByServer: pluginProbeRest,
openSubsonicExtensionsByServer: extRest,
};
});
},
+1
View File
@@ -121,6 +121,7 @@ export const useAuthStore = create<AuthState>()(
audiomuseNavidromeIssueByServer: {},
instantMixProbeByServer: {},
audiomusePluginProbeByServer: {},
openSubsonicExtensionsByServer: {},
isLoggedIn: false,
isConnecting: false,
connectionError: null,
+9
View File
@@ -280,6 +280,15 @@ export interface AuthState {
audiomusePluginProbeByServer: Record<string, AudiomusePluginProbeResult>;
setAudiomusePluginProbe: (serverId: string, result: AudiomusePluginProbeResult) => void;
/**
* Full OpenSubsonic extension list per server (from `getOpenSubsonicExtensions`).
* One probe answers every extension-gated feature (AudioMuse `sonicSimilarity`,
* `playbackReport`, …) instead of re-fetching per feature. Cleared on a server
* generation change so the next probe repopulates it.
*/
openSubsonicExtensionsByServer: Record<string, string[]>;
setOpenSubsonicExtensions: (serverId: string, extensions: string[]) => void;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
+6 -5
View File
@@ -1,4 +1,4 @@
import { reportNowPlaying } from '../api/subsonicScrobble';
import { playbackReportStart } from './playbackReportSession';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
@@ -413,10 +413,11 @@ export function runPlayTrack(
}, 500);
});
// Navidrome now-playing follows nowPlayingEnabled; Music Network now-playing
// follows scrobbling, as Last.fm now-playing did (runtime gates internally).
const { nowPlayingEnabled: npEnabled } = useAuthStore.getState();
if (npEnabled) reportNowPlaying(scopedTrack.id, playbackSid);
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
// internally). playbackReportStart opens the live FSM on extension-capable
// servers and falls back to the legacy presence call otherwise.
playbackReportStart(scopedTrack.id, playbackSid);
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: scopedTrack.title,
+142
View File
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../api/subsonicScrobble', () => ({
reportPlayback: vi.fn(() => Promise.resolve()),
reportNowPlaying: vi.fn(() => Promise.resolve()),
}));
vi.mock('../serverCapabilities/storeView', () => ({
isFeatureActiveForServer: vi.fn(),
}));
vi.mock('./authStore', () => ({
useAuthStore: { getState: vi.fn(() => ({ nowPlayingEnabled: true })) },
}));
vi.mock('./playbackProgress', () => ({
getPlaybackProgressSnapshot: vi.fn(() => ({ currentTime: 12, progress: 0, buffered: 0, buffering: false })),
}));
vi.mock('./playbackRateStore', () => ({
usePlaybackRateStore: {
getState: () => ({ enabled: false, strategy: 'speed_corrected', speed: 1, pitchSemitones: 0 }),
},
}));
vi.mock('../utils/audio/playbackRateHelpers', () => ({ isPlaybackRateApplied: () => false }));
vi.mock('../utils/orbit', () => ({ isOrbitPlaybackSyncActive: () => false }));
import { reportNowPlaying, reportPlayback } from '../api/subsonicScrobble';
import { isFeatureActiveForServer } from '../serverCapabilities/storeView';
import { useAuthStore } from './authStore';
import {
_resetPlaybackReportSessionForTest,
playbackReportPaused,
playbackReportPlaying,
playbackReportSeek,
playbackReportStart,
playbackReportStopped,
} from './playbackReportSession';
const reportPlaybackMock = vi.mocked(reportPlayback);
const reportNowPlayingMock = vi.mocked(reportNowPlaying);
const featureActiveMock = vi.mocked(isFeatureActiveForServer);
const authStateMock = vi.mocked(useAuthStore.getState);
const SID = 'srv-1';
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
function lastState(): string | undefined {
const calls = reportPlaybackMock.mock.calls;
const call = calls[calls.length - 1];
return call?.[1].state;
}
beforeEach(() => {
reportPlaybackMock.mockClear();
reportPlaybackMock.mockResolvedValue(undefined);
reportNowPlayingMock.mockClear();
featureActiveMock.mockReset();
featureActiveMock.mockReturnValue(true);
authStateMock.mockReturnValue({ nowPlayingEnabled: true } as never);
_resetPlaybackReportSessionForTest();
});
afterEach(() => {
_resetPlaybackReportSessionForTest();
});
describe('playbackReportStart', () => {
it('does nothing when now-playing is disabled', () => {
authStateMock.mockReturnValue({ nowPlayingEnabled: false } as never);
playbackReportStart('t1', SID);
expect(reportPlaybackMock).not.toHaveBeenCalled();
expect(reportNowPlayingMock).not.toHaveBeenCalled();
});
it('opens the FSM with starting then playing on a new track', async () => {
playbackReportStart('t1', SID);
expect(reportPlaybackMock).toHaveBeenCalledTimes(1);
expect(reportPlaybackMock.mock.calls[0][1]).toMatchObject({
mediaId: 't1',
state: 'starting',
positionMs: 12000,
playbackRate: 1,
ignoreScrobble: true,
});
await flush();
expect(reportPlaybackMock).toHaveBeenCalledTimes(2);
expect(reportPlaybackMock.mock.calls[1][1].state).toBe('playing');
});
it('falls back to the legacy presence call when the extension is absent', () => {
featureActiveMock.mockReturnValue(false);
playbackReportStart('t1', SID);
expect(reportPlaybackMock).not.toHaveBeenCalled();
expect(reportNowPlayingMock).toHaveBeenCalledWith('t1', SID);
});
});
describe('FSM transitions on an open session', () => {
beforeEach(async () => {
playbackReportStart('t1', SID);
await flush();
reportPlaybackMock.mockClear();
});
it('reports playing on heartbeat with the supplied position', () => {
playbackReportPlaying(30);
expect(reportPlaybackMock).toHaveBeenCalledTimes(1);
expect(reportPlaybackMock.mock.calls[0][1]).toMatchObject({ state: 'playing', positionMs: 30000 });
});
it('reports paused', () => {
playbackReportPaused(45);
expect(lastState()).toBe('paused');
expect(reportPlaybackMock.mock.calls[0][1].positionMs).toBe(45000);
});
it('reports the transport state on seek', () => {
playbackReportSeek(60, true);
expect(lastState()).toBe('playing');
playbackReportSeek(60, false);
expect(lastState()).toBe('paused');
});
it('reports stopped and clears the session', async () => {
await playbackReportStopped(90);
expect(lastState()).toBe('stopped');
reportPlaybackMock.mockClear();
// No session left: further FSM calls are inert.
playbackReportPlaying(100);
playbackReportPaused(100);
expect(reportPlaybackMock).not.toHaveBeenCalled();
});
});
describe('extension toggled off mid-session', () => {
it('stops emitting FSM reports once the server no longer advertises the extension', async () => {
playbackReportStart('t1', SID);
await flush();
reportPlaybackMock.mockClear();
featureActiveMock.mockReturnValue(false);
playbackReportPlaying(10);
playbackReportPaused(10);
expect(reportPlaybackMock).not.toHaveBeenCalled();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { reportNowPlaying, reportPlayback } from '../api/subsonicScrobble';
import type { PlaybackReportState } from '../api/subsonicTypes';
import { FEATURE_PLAYBACK_REPORT } from '../serverCapabilities/catalog';
import { isFeatureActiveForServer } from '../serverCapabilities/storeView';
import { isPlaybackRateApplied } from '../utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
import { useAuthStore } from './authStore';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlaybackRateStore } from './playbackRateStore';
/**
* Live now-playing presence on the Subsonic server channel.
*
* When the server advertises the OpenSubsonic `playbackReport` extension
* (Navidrome 0.62) we drive a small playback state machine starting
* playing paused stopped that mirrors the lifecycle hooks already used by
* `playListenSession`. This gives `getNowPlaying` a real transport state and an
* extrapolated position. `ignoreScrobble=true` keeps the server from applying
* scrobble / play-count side effects, because psysonic still owns play counts on
* the dedicated `scrobble.view` channel (the 50% rule in `audioEventHandlers`).
*
* On servers without the extension every entry point degrades to the legacy
* `scrobble.view?submission=false` presence call (`reportNowPlaying`), so the
* behaviour is unchanged there. All presence reporting stays gated on the
* existing `nowPlayingEnabled` master toggle.
*/
type ReportSession = { serverId: string; trackId: string };
let session: ReportSession | null = null;
function nowPlayingEnabled(): boolean {
return useAuthStore.getState().nowPlayingEnabled;
}
function extensionActive(serverId: string): boolean {
return isFeatureActiveForServer(serverId, FEATURE_PLAYBACK_REPORT);
}
/** Effective playback speed sent to the server (1.0 when the speed DSP is off). */
function effectivePlaybackRate(): number {
const { enabled, strategy, speed, pitchSemitones } = usePlaybackRateStore.getState();
return isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, isOrbitPlaybackSyncActive())
? speed
: 1.0;
}
function positionMs(explicitSec?: number): number {
const sec = explicitSec ?? getPlaybackProgressSnapshot().currentTime;
return Math.max(0, Math.floor((Number.isFinite(sec) ? sec : 0) * 1000));
}
function send(
serverId: string,
trackId: string,
state: PlaybackReportState,
explicitSec?: number,
): Promise<void> {
return reportPlayback(serverId, {
mediaId: trackId,
positionMs: positionMs(explicitSec),
state,
playbackRate: effectivePlaybackRate(),
ignoreScrobble: true,
});
}
/**
* Track start / gapless switch / queue restore. Replaces the direct
* `reportNowPlaying` presence call at those sites: the extension path opens the
* FSM (starting playing); otherwise the legacy presence call is used.
*/
export function playbackReportStart(trackId: string, serverId: string): void {
if (!serverId || !nowPlayingEnabled()) return;
if (!extensionActive(serverId)) {
void reportNowPlaying(trackId, serverId);
return;
}
const isNewSession = !session || session.trackId !== trackId || session.serverId !== serverId;
session = { serverId, trackId };
if (isNewSession) {
void send(serverId, trackId, 'starting').then(() => send(serverId, trackId, 'playing'));
} else {
void send(serverId, trackId, 'playing');
}
}
/** Engine-confirmed playback / resume / heartbeat (extension path only). */
export function playbackReportPlaying(explicitSec?: number): void {
if (!session || !nowPlayingEnabled() || !extensionActive(session.serverId)) return;
void send(session.serverId, session.trackId, 'playing', explicitSec);
}
/** Transport paused (extension path only). */
export function playbackReportPaused(explicitSec?: number): void {
if (!session || !nowPlayingEnabled() || !extensionActive(session.serverId)) return;
void send(session.serverId, session.trackId, 'paused', explicitSec);
}
/** Seek settled — report the new position with the current transport state. */
export function playbackReportSeek(explicitSec: number, isPlaying: boolean): void {
if (!session || !nowPlayingEnabled() || !extensionActive(session.serverId)) return;
void send(session.serverId, session.trackId, isPlaying ? 'playing' : 'paused', explicitSec);
}
/**
* Playback stopped (manual stop / ended / error / app quit). Clears the session
* and tells the server to drop the now-playing entry. Returns the in-flight
* request so the exit flow can race it against a timeout.
*/
export function playbackReportStopped(explicitSec?: number): Promise<void> {
if (!session) return Promise.resolve();
const { serverId, trackId } = session;
session = null;
if (!extensionActive(serverId)) return Promise.resolve();
return send(serverId, trackId, 'stopped', explicitSec);
}
/** Test-only reset. */
export function _resetPlaybackReportSessionForTest(): void {
session = null;
}
+6
View File
@@ -31,6 +31,7 @@ import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { playbackReportPlaying } from './playbackReportSession';
import { resumeRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
@@ -91,8 +92,10 @@ export function runResume(set: SetState, get: GetState): void {
invoke('audio_resume').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: true });
playbackReportPlaying(targetSec);
} else {
set({ isPlaying: true });
playbackReportPlaying(targetSec);
}
} else {
// Host has a different track — load it (`_orbitConfirmed=true`
@@ -127,6 +130,8 @@ export function runResume(set: SetState, get: GetState): void {
invoke('audio_resume').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: true });
// Mirror pause(): tell the server immediately, don't wait for `audio:playing`.
playbackReportPlaying(currentTime);
touchHotCacheOnPlayback(currentTrack.id, getPlaybackCacheServerKey());
} else {
// Engine has no loaded paused stream (app relaunch, or track ended and user
@@ -135,6 +140,7 @@ export function runResume(set: SetState, get: GetState): void {
const gen = bumpPlayGeneration();
const vol = get().volume;
set({ isPlaying: true });
playbackReportPlaying(currentTime);
void (async () => {
const authHot = useAuthStore.getState();
+4
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { playbackReportSeek } from './playbackReportSession';
import { isRecoverableSeekError } from '../utils/audio/seekErrors';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore';
@@ -48,6 +49,9 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
armSeekDebounce(100, () => {
const s0 = get();
if (!s0.currentTrack) return;
// Report the new position once the drag settles so live now-playing jumps to
// the seeked point instead of waiting for the next heartbeat.
playbackReportSeek(time, s0.isPlaying);
const sidSeek = getPlaybackServerId();
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
setSeekFallbackVisualTarget({
+5
View File
@@ -3,6 +3,7 @@ import { setIsAudioPaused } from './engineState';
import type { PlayerState } from './playerStoreTypes';
import { flushQueueSyncToServer } from './queueSync';
import { playListenSessionFinalize, playListenSessionOnPause } from './playListenSession';
import { playbackReportPaused, playbackReportStopped } from './playbackReportSession';
import { pauseRadio, stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
@@ -31,6 +32,9 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
return {
stop: () => {
void playListenSessionFinalize('stop');
// Report stopped before the position is reset below so the server drops the
// now-playing entry at the right point (playbackReport extension).
void playbackReportStopped();
clearAllPlaybackScheduleTimers();
const wasRadio = !!get().currentRadio;
if (wasRadio) {
@@ -74,6 +78,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
} else {
invoke('audio_pause').catch(console.error);
setIsAudioPaused(true);
playbackReportPaused(get().currentTime);
// Flush position so a quick close after pause still leaves the
// server with the right resume point for other devices.
const s = get();