diff --git a/CHANGELOG.md b/CHANGELOG.md index 001faccd..13d998f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/api/subsonic.scheduleProbe.test.ts b/src/api/subsonic.scheduleProbe.test.ts index e4e0eed6..410ae5d5 100644 --- a/src/api/subsonic.scheduleProbe.test.ts +++ b/src/api/subsonic.scheduleProbe.test.ts @@ -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)', () => { diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 1229559e..ab3e91f0 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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'); + } }); } } diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index 98e3ffbc..9b2336cc 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -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 { + if (!serverId) return; + if (!shouldAttemptSubsonicForServer(serverId)) return; + const query: Record = { + 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 { try { const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying | SubsonicNowPlaying[] } }>('getNowPlaying.view', { _t: Date.now() }); diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 5ccdd332..7222f410 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -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 { diff --git a/src/components/NowPlayingDropdown.tsx b/src/components/NowPlayingDropdown.tsx index f7ac9d3f..366a0189 100644 --- a/src/components/NowPlayingDropdown.tsx +++ b/src/components/NowPlayingDropdown.tsx @@ -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(null); const panelRef = useRef(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() { {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })} )} + {(() => { + const posSec = livePositionSec(stream); + if (posSec === undefined || stream.duration <= 0) return null; + const playing = stream.state === 'playing'; + return ( +
+ {stream.state === 'paused' && } +
+
+
+
+
+ + {/* ~2ch reserve inside the current-time box (9:59→10:00), not empty gap before the bar. */} + + {formatClock(posSec)} + + {' / '} + {formatClock(stream.duration)} + +
+ ); + })()}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 6f86be8b..d463b7e8 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/hooks/tauriBridge/useMediaAndWindowBridge.ts b/src/hooks/tauriBridge/useMediaAndWindowBridge.ts index be8cb5af..6be04a76 100644 --- a/src/hooks/tauriBridge/useMediaAndWindowBridge.ts +++ b/src/hooks/tauriBridge/useMediaAndWindowBridge.ts @@ -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)), diff --git a/src/serverCapabilities/catalog.ts b/src/serverCapabilities/catalog.ts index 808a6be8..4da8fdf6 100644 --- a/src/serverCapabilities/catalog.ts +++ b/src/serverCapabilities/catalog.ts @@ -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 { diff --git a/src/serverCapabilities/resolve.test.ts b/src/serverCapabilities/resolve.test.ts index 63853041..d9b441b3 100644 --- a/src/serverCapabilities/resolve.test.ts +++ b/src/serverCapabilities/resolve.test.ts @@ -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'); }); }); diff --git a/src/serverCapabilities/storeView.test.ts b/src/serverCapabilities/storeView.test.ts index c261e409..6ba73a20 100644 --- a/src/serverCapabilities/storeView.test.ts +++ b/src/serverCapabilities/storeView.test.ts @@ -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, extra: Record 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); + }); }); diff --git a/src/serverCapabilities/storeView.ts b/src/serverCapabilities/storeView.ts index 2baacef8..4a9ce57d 100644 --- a/src/serverCapabilities/storeView.ts +++ b/src/serverCapabilities/storeView.ts @@ -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 { 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]), }; } diff --git a/src/store/applyQueueHistorySnapshot.ts b/src/store/applyQueueHistorySnapshot.ts index b750c274..2f59b850 100644 --- a/src/store/applyQueueHistorySnapshot.ts +++ b/src/store/applyQueueHistorySnapshot.ts @@ -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, diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index fd9d5244..1ba35cd1 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -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'); diff --git a/src/store/authPerServerCapabilityActions.ts b/src/store/authPerServerCapabilityActions.ts index 9ef27957..16cb1f9e 100644 --- a/src/store/authPerServerCapabilityActions.ts +++ b/src/store/authPerServerCapabilityActions.ts @@ -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 diff --git a/src/store/authServerProfileActions.ts b/src/store/authServerProfileActions.ts index 77b68f6a..2b263e16 100644 --- a/src/store/authServerProfileActions.ts +++ b/src/store/authServerProfileActions.ts @@ -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, }; }); }, diff --git a/src/store/authStore.ts b/src/store/authStore.ts index c36cf8ff..2cb68483 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -121,6 +121,7 @@ export const useAuthStore = create()( audiomuseNavidromeIssueByServer: {}, instantMixProbeByServer: {}, audiomusePluginProbeByServer: {}, + openSubsonicExtensionsByServer: {}, isLoggedIn: false, isConnecting: false, connectionError: null, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index f043edb8..170b739e 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -280,6 +280,15 @@ export interface AuthState { audiomusePluginProbeByServer: Record; 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; + setOpenSubsonicExtensions: (serverId: string, extensions: string[]) => void; + // Status isLoggedIn: boolean; isConnecting: boolean; diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index cc64b1d3..d0469039 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -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, diff --git a/src/store/playbackReportSession.test.ts b/src/store/playbackReportSession.test.ts new file mode 100644 index 00000000..5528596d --- /dev/null +++ b/src/store/playbackReportSession.test.ts @@ -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(); + }); +}); diff --git a/src/store/playbackReportSession.ts b/src/store/playbackReportSession.ts new file mode 100644 index 00000000..08502c87 --- /dev/null +++ b/src/store/playbackReportSession.ts @@ -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 { + 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 { + 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; +} diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index 5c34e14d..2d561cb3 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -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(); diff --git a/src/store/seekAction.ts b/src/store/seekAction.ts index db71b649..d7869e21 100644 --- a/src/store/seekAction.ts +++ b/src/store/seekAction.ts @@ -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({ diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index d0e2c7f4..76e2076d 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -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();