From 584abade5dc9aa5dd69894678a6daadb4d077edc Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:51:24 +0300 Subject: [PATCH 1/2] feat(frontend): show stale progress when music SSE lags Track lastStateUpdate from SSE/REST state and from real heartbeat data payloads. While playing, mark the bar stale after 45s without a stamp (1.5x the 30s server heartbeat). Drop dead aria-label markup and keep timestamps from shifting when the outdated notice shows. --- .../backend/src/routes/music/stateRoutes.ts | 6 ++- .../frontend/src/hooks/useMusicPlayer.test.ts | 37 +++++++++++++- packages/frontend/src/hooks/useMusicPlayer.ts | 29 ++++++++--- packages/frontend/src/locales/en.json | 5 +- packages/frontend/src/locales/pt-BR.json | 5 +- packages/frontend/src/pages/Music.tsx | 49 +++++++++++++++++-- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/routes/music/stateRoutes.ts b/packages/backend/src/routes/music/stateRoutes.ts index 417a0e5bb..88d7baa2a 100644 --- a/packages/backend/src/routes/music/stateRoutes.ts +++ b/packages/backend/src/routes/music/stateRoutes.ts @@ -51,7 +51,11 @@ export function setupStateRoutes(app: Express): void { return } try { - res.write(': heartbeat\n\n') + // Real data: payload (not an SSE comment) so clients can + // treat the heartbeat as a liveness stamp for progress UI. + res.write( + `data: ${JSON.stringify({ type: 'heartbeat' })}\n\n`, + ) } catch { // NOSONAR: Intentionally swallowing heartbeat write exceptions — client disconnected mid-send. The close event handler will clean up the connection. } diff --git a/packages/frontend/src/hooks/useMusicPlayer.test.ts b/packages/frontend/src/hooks/useMusicPlayer.test.ts index 0bd9c0274..a6106a6ea 100644 --- a/packages/frontend/src/hooks/useMusicPlayer.test.ts +++ b/packages/frontend/src/hooks/useMusicPlayer.test.ts @@ -112,6 +112,20 @@ describe('useMusicPlayer', () => { await waitFor(() => { expect(result.current.state.volume).toBe(80) }) + expect(result.current.lastStateUpdate).toEqual(expect.any(Number)) + }) + + test('sets lastStateUpdate from initial REST state', async () => { + const { sse } = makeMockSSE() + mockCreateSSEConnection.mockReturnValue(sse) + const restState = { guildId: 'guild-1', isPlaying: true, tracks: [], currentTrack: null, isPaused: false, volume: 60, repeatMode: 'off', shuffled: false, position: 0, voiceChannelId: null, voiceChannelName: null, timestamp: 0 } + mockGetState.mockResolvedValue({ data: restState }) + + const { result } = renderHook(() => useMusicPlayer('guild-1')) + + await waitFor(() => { + expect(result.current.lastStateUpdate).toEqual(expect.any(Number)) + }) }) test('ignores malformed SSE messages gracefully', async () => { @@ -122,10 +136,31 @@ describe('useMusicPlayer', () => { const volumeBefore = result.current.state.volume act(() => { - listeners.onmessage?.({ data: ': heartbeat' }) + listeners.onmessage?.({ data: 'not-json' }) + }) + + expect(result.current.state.volume).toBe(volumeBefore) + }) + + test('heartbeat payload refreshes lastStateUpdate without changing state', async () => { + const { sse, listeners } = makeMockSSE() + mockCreateSSEConnection.mockReturnValue(sse) + + const { result } = renderHook(() => useMusicPlayer('guild-1')) + const volumeBefore = result.current.state.volume + const before = result.current.lastStateUpdate + + act(() => { + listeners.onmessage?.({ + data: JSON.stringify({ type: 'heartbeat' }), + }) }) expect(result.current.state.volume).toBe(volumeBefore) + expect(result.current.lastStateUpdate).toEqual(expect.any(Number)) + if (before !== null) { + expect(result.current.lastStateUpdate).toBeGreaterThanOrEqual(before) + } }) test('sets isConnected false and calls close on SSE error', async () => { diff --git a/packages/frontend/src/hooks/useMusicPlayer.ts b/packages/frontend/src/hooks/useMusicPlayer.ts index f7d789ab2..bbc3c688f 100644 --- a/packages/frontend/src/hooks/useMusicPlayer.ts +++ b/packages/frontend/src/hooks/useMusicPlayer.ts @@ -26,14 +26,22 @@ export function useMusicPlayer(guildId: string | undefined) { const [isLoading, setIsLoading] = useState(false) const [isConnected, setIsConnected] = useState(false) const [error, setError] = useState(null) + /** Wall-clock ms of the last successful state payload (SSE or REST). */ + const [lastStateUpdate, setLastStateUpdate] = useState(null) const sseRef = useRef(null) const retryRef = useRef(0) const retryTimerRef = useRef | null>(null) + const applyState = useCallback((next: QueueState) => { + setState(next) + setLastStateUpdate(Date.now()) + }, []) + useEffect(() => { if (!guildId) { setState(EMPTY_STATE) setIsConnected(false) + setLastStateUpdate(null) return } @@ -54,9 +62,17 @@ export function useMusicPlayer(guildId: string | undefined) { sse.onmessage = (event) => { if (cancelled) return try { - setState(JSON.parse(event.data)) + const payload = JSON.parse(event.data) as { + type?: string + } & QueueState + // Heartbeat is liveness only; do not clobber queue state. + if (payload?.type === 'heartbeat') { + setLastStateUpdate(Date.now()) + return + } + applyState(payload) } catch { - /* heartbeat or malformed data */ + /* malformed data */ } } @@ -81,7 +97,7 @@ export function useMusicPlayer(guildId: string | undefined) { api.music .getState(guildId) .then((res) => { - if (!cancelled) setState(res.data) + if (!cancelled) applyState(res.data) }) .catch(() => {}) @@ -92,7 +108,7 @@ export function useMusicPlayer(guildId: string | undefined) { if (retryTimerRef.current) clearTimeout(retryTimerRef.current) setIsConnected(false) } - }, [guildId]) + }, [guildId, applyState]) const sendCommand = useCallback( async ( @@ -113,7 +129,7 @@ export function useMusicPlayer(guildId: string | undefined) { if (optimistic) { api.music .getState(guildId) - .then((res) => setState(res.data)) + .then((res) => applyState(res.data)) .catch(() => {}) } setError(err instanceof Error ? err.message : 'Command failed') @@ -121,7 +137,7 @@ export function useMusicPlayer(guildId: string | undefined) { setIsLoading(false) } }, - [guildId], + [guildId, applyState], ) const commands = useMusicCommands(guildId, sendCommand, state.tracks) @@ -131,6 +147,7 @@ export function useMusicPlayer(guildId: string | undefined) { isLoading, isConnected, error, + lastStateUpdate, ...commands, } } diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 6c8a8b903..67df58e3d 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -766,7 +766,10 @@ "play": "Play", "pause": "Pause", "nextTrack": "Next track", - "repeatMode": "Repeat: {{mode}}" + "repeatMode": "Repeat: {{mode}}", + "progressMayBeOutdated": "may be outdated", + "progressMayBeOutdatedTitle": "Playback position may be outdated", + "progressMayBeOutdatedAria": "Progress may be outdated" }, "preferredArtists": { "noServerSelected": "No Server Selected", diff --git a/packages/frontend/src/locales/pt-BR.json b/packages/frontend/src/locales/pt-BR.json index ad9843ce7..390bf94d6 100644 --- a/packages/frontend/src/locales/pt-BR.json +++ b/packages/frontend/src/locales/pt-BR.json @@ -762,7 +762,10 @@ "play": "Reproduzir", "pause": "Pausar", "nextTrack": "Próxima faixa", - "repeatMode": "Repetição: {{mode}}" + "repeatMode": "Repetição: {{mode}}", + "progressMayBeOutdated": "pode estar desatualizado", + "progressMayBeOutdatedTitle": "A posição da reprodução pode estar desatualizada", + "progressMayBeOutdatedAria": "Progresso pode estar desatualizado" }, "preferredArtists": { "noServerSelected": "Nenhum Servidor Selecionado", diff --git a/packages/frontend/src/pages/Music.tsx b/packages/frontend/src/pages/Music.tsx index 952796e13..c4dcd90bc 100644 --- a/packages/frontend/src/pages/Music.tsx +++ b/packages/frontend/src/pages/Music.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Music2, @@ -92,6 +92,7 @@ export default function MusicPage() { player.previous()} onSkip={() => player.skip()} @@ -139,8 +140,15 @@ export default function MusicPage() { ) } +/** + * Progress is stale when no SSE/REST state or heartbeat arrives this long. + * Server heartbeat is 30s (stateRoutes stream); 1.5x that misses one ping. + */ +const STALE_AFTER_MS = 45_000 + function NowPlayingHero({ state, + lastStateUpdate, onPlayPause, onPrevious, onSkip, @@ -149,6 +157,7 @@ function NowPlayingHero({ onVolumeChange, }: { state: QueueState + lastStateUpdate: number | null onPlayPause: () => void onPrevious: () => void onSkip: () => void @@ -158,6 +167,19 @@ function NowPlayingHero({ }) { const { t } = useTranslation() const currentTrack = state.tracks[0] + const [now, setNow] = useState(() => Date.now()) + + // Re-tick while playing so the bar can flip to stale without a new SSE event. + useEffect(() => { + if (!state.isPlaying) return + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [state.isPlaying]) + + const isStale = + state.isPlaying && + lastStateUpdate !== null && + now - lastStateUpdate > STALE_AFTER_MS if (!currentTrack) { return ( @@ -215,15 +237,34 @@ function NowPlayingHero({
-
+
-
+ {/* Relative wrapper so the stale notice does not shift the timestamps. */} +
{formatSeconds(position)} + {isStale ? ( + + {t('music.progressMayBeOutdated')} + + ) : null} {formatSeconds(duration)}
From cb93285194fc69158aa01fd7ccf9d66523cab7f9 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:18:55 +0300 Subject: [PATCH 2/2] fix(frontend): announce stale progress via role=status --- packages/frontend/src/locales/en.json | 4 +--- packages/frontend/src/locales/pt-BR.json | 4 +--- packages/frontend/src/pages/Music.tsx | 10 ++++------ 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 67df58e3d..cf641d9b2 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -767,9 +767,7 @@ "pause": "Pause", "nextTrack": "Next track", "repeatMode": "Repeat: {{mode}}", - "progressMayBeOutdated": "may be outdated", - "progressMayBeOutdatedTitle": "Playback position may be outdated", - "progressMayBeOutdatedAria": "Progress may be outdated" + "progressMayBeOutdated": "may be outdated" }, "preferredArtists": { "noServerSelected": "No Server Selected", diff --git a/packages/frontend/src/locales/pt-BR.json b/packages/frontend/src/locales/pt-BR.json index 390bf94d6..517872f67 100644 --- a/packages/frontend/src/locales/pt-BR.json +++ b/packages/frontend/src/locales/pt-BR.json @@ -763,9 +763,7 @@ "pause": "Pausar", "nextTrack": "Próxima faixa", "repeatMode": "Repetição: {{mode}}", - "progressMayBeOutdated": "pode estar desatualizado", - "progressMayBeOutdatedTitle": "A posição da reprodução pode estar desatualizada", - "progressMayBeOutdatedAria": "Progresso pode estar desatualizado" + "progressMayBeOutdated": "pode estar desatualizado" }, "preferredArtists": { "noServerSelected": "Nenhum Servidor Selecionado", diff --git a/packages/frontend/src/pages/Music.tsx b/packages/frontend/src/pages/Music.tsx index c4dcd90bc..7e38a94ce 100644 --- a/packages/frontend/src/pages/Music.tsx +++ b/packages/frontend/src/pages/Music.tsx @@ -241,11 +241,6 @@ function NowPlayingHero({ className={`flex-1 h-1 bg-lucky-bg-active rounded-full overflow-hidden ${ isStale ? 'opacity-50' : '' }`} - title={ - isStale - ? t('music.progressMayBeOutdatedTitle') - : undefined - } >
{formatSeconds(position)} {isStale ? ( - + {t('music.progressMayBeOutdated')} ) : null}