From ee044ece1a3f35346a487557db78efaf7b1054c3 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:22:35 +0300 Subject: [PATCH] fix(now-playing): poll Live listener count every 30s in the background (#1125) --- CHANGELOG.md | 6 ++++ src/components/NowPlayingDropdown.tsx | 47 +++++++++++++++++---------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26157a3a..b51b8254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +### 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 diff --git a/src/components/NowPlayingDropdown.tsx b/src/components/NowPlayingDropdown.tsx index 8a94a37d..82d68eb3 100644 --- a/src/components/NowPlayingDropdown.tsx +++ b/src/components/NowPlayingDropdown.tsx @@ -23,13 +23,14 @@ 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. + // Wall-clock baseline for the last poll: between polls we extrapolate the + // position of `playing` entries locally so the progress bar glides instead + // of snapping. The server already extrapolates positionMs at fetch time. const fetchedAtRef = useRef(0); const [, forceTick] = useState(0); const PANEL_WIDTH = 340; + const LIVE_POLL_OPEN_MS = 10_000; + const LIVE_POLL_BACKGROUND_MS = 30_000; const formatClock = (totalSec: number) => { const s = Math.max(0, Math.floor(totalSec)); @@ -61,8 +62,8 @@ export default function NowPlayingDropdown() { setPanelPos({ top, left }); }, []); - const fetchNowPlaying = async () => { - setLoading(true); + const fetchNowPlaying = useCallback(async (opts?: { silent?: boolean }) => { + if (!opts?.silent) setLoading(true); try { const data = await getNowPlaying(); fetchedAtRef.current = Date.now(); @@ -70,9 +71,9 @@ export default function NowPlayingDropdown() { } catch (e) { console.error('Failed to load Now Playing', e); } finally { - setLoading(false); + if (!opts?.silent) setLoading(false); } - }; + }, []); const handleRefresh = () => { 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(() => { - if (!isOpen) return; - fetchNowPlaying(); - const id = setInterval(() => { - if (document.visibilityState === 'visible') fetchNowPlaying(); - }, 10000); - return () => clearInterval(id); - }, [isOpen]); + const poll = (silent: boolean) => { + if (document.visibilityState === 'visible') void fetchNowPlaying({ silent }); + }; + + poll(!isOpen); + const intervalMs = isOpen ? LIVE_POLL_OPEN_MS : LIVE_POLL_BACKGROUND_MS; + 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 - // locally-extrapolated bar advances smoothly between the 10 s polls. + // locally-extrapolated bar advances smoothly between polls. const hasLivePosition = nowPlaying.some( e => e.state === 'playing' && typeof e.positionMs === 'number', );