mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -121,6 +121,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomusePluginProbeByServer: {},
|
||||
openSubsonicExtensionsByServer: {},
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user