fix(now-playing): poll Live listener count every 30s in the background (#1125)

This commit is contained in:
cucadmuh
2026-06-18 21:22:35 +03:00
committed by GitHub
parent fde7ab432f
commit ee044ece1a
2 changed files with 36 additions and 17 deletions
+6
View File
@@ -67,6 +67,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Bumped transitive `form-data` 4.0.5 → 4.0.6 (via axios) to close Dependabot alert [#18](https://github.com/Psychotoxical/psysonic/security/dependabot/18) for CRLF injection in multipart field names (CVE-2026-12143). Psysonic only uses axios for GET requests, so exploitability was low; the lockfile bump clears the advisory. * Bumped transitive `form-data` 4.0.5 → 4.0.6 (via axios) to close Dependabot alert [#18](https://github.com/Psychotoxical/psysonic/security/dependabot/18) for CRLF injection in multipart field names (CVE-2026-12143). Psysonic only uses axios for GET requests, so exploitability was low; the lockfile bump clears the advisory.
### Live listener badge stale when the popover was closed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1125](https://github.com/Psychotoxical/psysonic/pull/1125)**
* The Live header badge only refreshed `getNowPlaying` while the "Who is listening?" popover was open, so the listener count could stay stale or hidden until opened. Poll every 30 s while the window is visible (10 s while the popover is open); background fetches are silent so the header does not flash a loading state.
## [1.48.1] - 2026-06-15 ## [1.48.1] - 2026-06-15
+30 -17
View File
@@ -23,13 +23,14 @@ export default function NowPlayingDropdown() {
const triggerWrapRef = useRef<HTMLDivElement>(null); const triggerWrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); 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 // Wall-clock baseline for the last poll: between polls we extrapolate the
// extrapolate the position of `playing` entries locally so the progress bar // position of `playing` entries locally so the progress bar glides instead
// glides instead of snapping. The server already extrapolates positionMs at // of snapping. The server already extrapolates positionMs at fetch time.
// fetch time, so this just continues from there using the reported speed.
const fetchedAtRef = useRef(0); const fetchedAtRef = useRef(0);
const [, forceTick] = useState(0); const [, forceTick] = useState(0);
const PANEL_WIDTH = 340; const PANEL_WIDTH = 340;
const LIVE_POLL_OPEN_MS = 10_000;
const LIVE_POLL_BACKGROUND_MS = 30_000;
const formatClock = (totalSec: number) => { const formatClock = (totalSec: number) => {
const s = Math.max(0, Math.floor(totalSec)); const s = Math.max(0, Math.floor(totalSec));
@@ -61,8 +62,8 @@ export default function NowPlayingDropdown() {
setPanelPos({ top, left }); setPanelPos({ top, left });
}, []); }, []);
const fetchNowPlaying = async () => { const fetchNowPlaying = useCallback(async (opts?: { silent?: boolean }) => {
setLoading(true); if (!opts?.silent) setLoading(true);
try { try {
const data = await getNowPlaying(); const data = await getNowPlaying();
fetchedAtRef.current = Date.now(); fetchedAtRef.current = Date.now();
@@ -70,9 +71,9 @@ export default function NowPlayingDropdown() {
} catch (e) { } catch (e) {
console.error('Failed to load Now Playing', e); console.error('Failed to load Now Playing', e);
} finally { } finally {
setLoading(false); if (!opts?.silent) setLoading(false);
} }
}; }, []);
const handleRefresh = () => { const handleRefresh = () => {
setSpinning(true); setSpinning(true);
@@ -81,18 +82,30 @@ export default function NowPlayingDropdown() {
}); });
}; };
// Poll only while the dropdown is open AND the page is visible. // Poll while the page is visible: every 30 s for the header badge, every 10 s
// while the popover is open so the list stays fresher.
useEffect(() => { useEffect(() => {
if (!isOpen) return; const poll = (silent: boolean) => {
fetchNowPlaying(); if (document.visibilityState === 'visible') void fetchNowPlaying({ silent });
const id = setInterval(() => { };
if (document.visibilityState === 'visible') fetchNowPlaying();
}, 10000); poll(!isOpen);
return () => clearInterval(id); const intervalMs = isOpen ? LIVE_POLL_OPEN_MS : LIVE_POLL_BACKGROUND_MS;
}, [isOpen]); const id = setInterval(() => poll(true), intervalMs);
const onVisibility = () => {
if (document.visibilityState === 'visible') poll(true);
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [isOpen, fetchNowPlaying]);
// Re-render once per second while a `playing` entry exposes a position, so the // Re-render once per second while a `playing` entry exposes a position, so the
// locally-extrapolated bar advances smoothly between the 10 s polls. // locally-extrapolated bar advances smoothly between polls.
const hasLivePosition = nowPlaying.some( const hasLivePosition = nowPlaying.some(
e => e.state === 'playing' && typeof e.positionMs === 'number', e => e.state === 'playing' && typeof e.positionMs === 'number',
); );