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

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

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

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

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

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

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

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

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

* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
This commit is contained in:
cucadmuh
2026-06-13 05:31:26 +03:00
committed by GitHub
parent abc2c0b579
commit 891ab0dd5b
24 changed files with 601 additions and 33 deletions
+66 -1
View File
@@ -4,7 +4,7 @@ import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { PlayCircle, User, Clock, Radio, RefreshCw } from 'lucide-react';
import { PlayCircle, Pause, User, Clock, Radio, RefreshCw } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -22,8 +22,33 @@ export default function NowPlayingDropdown() {
const triggerWrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
// Wall-clock baseline for the last poll: between polls (every 10 s) we
// extrapolate the position of `playing` entries locally so the progress bar
// glides instead of snapping. The server already extrapolates positionMs at
// fetch time, so this just continues from there using the reported speed.
const fetchedAtRef = useRef(0);
const [, forceTick] = useState(0);
const PANEL_WIDTH = 340;
const formatClock = (totalSec: number) => {
const s = Math.max(0, Math.floor(totalSec));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
};
// Live position in seconds: advance `playing` entries by elapsed × playbackRate
// since the last poll; freeze everything else at the reported position.
const livePositionSec = (entry: SubsonicNowPlaying): number | undefined => {
if (typeof entry.positionMs !== 'number') return undefined;
let ms = entry.positionMs;
if (entry.state === 'playing') {
const rate = entry.playbackRate && entry.playbackRate > 0 ? entry.playbackRate : 1;
ms += (Date.now() - fetchedAtRef.current) * rate;
}
const maxMs = entry.duration > 0 ? entry.duration * 1000 : ms;
return Math.min(ms, maxMs) / 1000;
};
const updatePanelPos = useCallback(() => {
const el = triggerWrapRef.current;
if (!el) return;
@@ -39,6 +64,7 @@ export default function NowPlayingDropdown() {
setLoading(true);
try {
const data = await getNowPlaying();
fetchedAtRef.current = Date.now();
setNowPlaying(data);
} catch (e) {
console.error('Failed to load Now Playing', e);
@@ -64,6 +90,17 @@ export default function NowPlayingDropdown() {
return () => clearInterval(id);
}, [isOpen]);
// Re-render once per second while a `playing` entry exposes a position, so the
// locally-extrapolated bar advances smoothly between the 10 s polls.
const hasLivePosition = nowPlaying.some(
e => e.state === 'playing' && typeof e.positionMs === 'number',
);
useEffect(() => {
if (!isOpen || !hasLivePosition) return;
const id = setInterval(() => forceTick(v => v + 1), 1000);
return () => clearInterval(id);
}, [isOpen, hasLivePosition]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePanelPos();
@@ -193,6 +230,34 @@ export default function NowPlayingDropdown() {
<span className="truncate">{t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>
</div>
)}
{(() => {
const posSec = livePositionSec(stream);
if (posSec === undefined || stream.duration <= 0) return null;
const playing = stream.state === 'playing';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, marginTop: '1px' }}>
{stream.state === 'paused' && <Pause size={10} style={{ flexShrink: 0 }} />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ height: '3px', borderRadius: '2px', background: 'var(--border-subtle)', overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, (posSec / stream.duration) * 100))}%`,
height: '100%',
background: playing ? 'var(--accent)' : 'var(--text-muted)',
transition: playing ? 'width 1s linear' : 'none',
}} />
</div>
</div>
<span style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>
{/* ~2ch reserve inside the current-time box (9:59→10:00), not empty gap before the bar. */}
<span style={{ display: 'inline-block', minWidth: '6ch', textAlign: 'right' }}>
{formatClock(posSec)}
</span>
{' / '}
{formatClock(stream.duration)}
</span>
</div>
);
})()}
</div>
</div>
</div>