mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user