diff --git a/backend/src/routes/playback.routes.ts b/backend/src/routes/playback.routes.ts index e143b96..1d03cfe 100644 --- a/backend/src/routes/playback.routes.ts +++ b/backend/src/routes/playback.routes.ts @@ -200,9 +200,13 @@ export default async function playbackRoutes( request.raw.on('close', () => { clearInterval(heartbeat); unsubscribe(); - // A closed tab must not keep the session hostage: hand ownership back so - // any other device can pick the same track up where this one left it. - playbackSync.releaseIfOwner(userId, deviceId).catch(() => {}); + // Ownership deliberately survives a closed stream. A phone changing cell, + // locking its screen or dozing drops this connection for a few seconds + // while its audio keeps playing; releasing here published an unowned + // session, which the phone then read as "something else took over" and + // paused itself. A device that is really gone loses the session two other + // ways: `pagehide` releases it outright, and the stale-device sweep frees + // an owner whose heartbeat has been silent past DEVICE_STALE_MS. }); }); } diff --git a/frontend/src/hooks/usePlaybackSync.test.tsx b/frontend/src/hooks/usePlaybackSync.test.tsx index f6587fe..1138505 100644 --- a/frontend/src/hooks/usePlaybackSync.test.tsx +++ b/frontend/src/hooks/usePlaybackSync.test.tsx @@ -1,7 +1,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { usePlaybackStore } from '../store/usePlaybackStore'; -import type { PlaybackSnapshot, PlaybackSyncEvent } from '../services/playbackSync'; +import { playbackSyncService, type PlaybackSnapshot, type PlaybackSyncEvent } from '../services/playbackSync'; import { usePlaybackSync } from './usePlaybackSync'; const THIS_DEVICE = '11111111-1111-1111-1111-111111111111'; @@ -94,4 +94,23 @@ describe('usePlaybackSync', () => { expect(usePlaybackStore.getState().position).toBe(55); expect(view.result.current.hasRemoteOwner).toBe(true); }); + + it('keeps playing and reclaims the session when a dropped stream leaves it unowned', async () => { + await mountOwning(); + act(() => usePlaybackStore.setState({ + isPlaying: true, + position: 48, + currentTrack: { id: 'track-1' } as never, + })); + + // What the server publishes after this device's stream breaks: nobody owns + // the audio, and the position is this device's own report from 18s ago. + await act(async () => { + emit!({ type: 'state', state: snapshot({ deviceId: null, position: 30, version: 4 }), devices: [] }); + }); + + expect(usePlaybackStore.getState().isPlaying).toBe(true); + expect(usePlaybackStore.getState().position).toBe(48); + expect(playbackSyncService.reportState).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/hooks/usePlaybackSync.ts b/frontend/src/hooks/usePlaybackSync.ts index b2ad2bb..e2de0d6 100644 --- a/frontend/src/hooks/usePlaybackSync.ts +++ b/frontend/src/hooks/usePlaybackSync.ts @@ -24,6 +24,9 @@ import type { Track } from '../types'; /** Position drifts constantly; anything faster than this is noise on the wire. */ const POSITION_REPORT_MS = 10_000; +/** Waits between attempts to register this device when the network is down. */ +const REGISTER_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000]; + export interface PlaybackSyncApi { deviceId: string | null; devices: PlaybackDevice[]; @@ -86,6 +89,17 @@ export function usePlaybackSync(): PlaybackSyncApi { // stays the authority on its own position. if (iOwnIt && alreadyOwnedIt) return; + // Nobody holds the session. That is not an instruction to stop: it is what + // the server publishes when this device's stream broke for a moment, or + // when the sweep freed an owner it thought was gone. A device with audio + // loaded claims the session back instead of pausing and rewinding to the + // position it last reported, which is how a phone losing signal for ten + // seconds used to stop playing. + if (state.deviceId === null && store.currentTrack) { + if (alreadyOwnedIt || store.isPlaying) void reportNow(); + return; + } + applyingRemote.current = true; try { if (!iOwnIt) { @@ -112,7 +126,7 @@ export function usePlaybackSync(): PlaybackSyncApi { } finally { applyingRemote.current = false; } - }, []); + }, [reportNow]); const applyCommand = useCallback(async (command: PlaybackCommand) => { const store = usePlaybackStore.getState(); @@ -142,10 +156,19 @@ export function usePlaybackSync(): PlaybackSyncApi { useEffect(() => { let closeStream: (() => void) | null = null; let cancelled = false; + let retry: ReturnType | undefined; - (async () => { + // Registration is one request, and a phone woken with no network fails it. + // Giving up there left that tab with no device id and no stream until the + // listener reloaded the page, so keep asking instead. + const attempt = async (attemptCount: number) => { const device = await playbackSyncService.register().catch(() => null); - if (!device || cancelled) return; + if (cancelled) return; + if (!device) { + const wait = REGISTER_BACKOFF_MS[Math.min(attemptCount, REGISTER_BACKOFF_MS.length - 1)]; + retry = setTimeout(() => void attempt(attemptCount + 1), wait); + return; + } setDeviceId(device.id); deviceIdRef.current = device.id; closeStream = playbackSyncService.openStream(device.id, (event) => { @@ -156,10 +179,12 @@ export function usePlaybackSync(): PlaybackSyncApi { void applyCommand(event.command); } }); - })(); + }; + void attempt(0); return () => { cancelled = true; + clearTimeout(retry); closeStream?.(); }; }, [applySnapshot, applyCommand]); diff --git a/frontend/src/services/playbackSync.ts b/frontend/src/services/playbackSync.ts index d996e8c..50df5f7 100644 --- a/frontend/src/services/playbackSync.ts +++ b/frontend/src/services/playbackSync.ts @@ -9,6 +9,9 @@ import type { Track } from '../types'; const DEVICE_ID_KEY = 'muzick.deviceId'; +/** Waits before reopening a push stream the server closed with an error. */ +const STREAM_REOPEN_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000]; + export type PlaybackCommand = | { type: 'play' | 'pause' | 'next' | 'prev' } | { type: 'seek'; position: number } @@ -113,20 +116,62 @@ export const playbackSyncService = { }, /** - * Open the push channel. EventSource reconnects on its own, and the server - * resends the full snapshot on connect, so a dropped stream self-heals - * without any resume bookkeeping here. + * Open the push channel and keep it open. The server resends the full + * snapshot on every connect, so a reconnection needs no resume bookkeeping. + * + * EventSource retries a dropped connection by itself, but only that case: an + * error status from the server — a backend restart, a proxy 502, a captive + * portal answering for it — closes the stream for good. A phone that hit one + * of those stayed silent until the page was reloaded, so reopen it here, and + * check the moment the network or the tab comes back rather than waiting out + * a backoff the listener is watching. */ openStream(deviceId: string, onEvent: (event: PlaybackSyncEvent) => void): () => void { const base = (import.meta.env.VITE_API_URL as string | undefined) || '/api'; - const source = new EventSource(`${base}/playback/stream?deviceId=${encodeURIComponent(deviceId)}`); - source.onmessage = (message) => { - try { - onEvent(JSON.parse(message.data) as PlaybackSyncEvent); - } catch { - // A malformed frame is not worth tearing the stream down for. - } + const url = `${base}/playback/stream?deviceId=${encodeURIComponent(deviceId)}`; + let source: EventSource | null = null; + let timer: ReturnType | undefined; + let attempt = 0; + let closed = false; + + const open = () => { + if (closed) return; + clearTimeout(timer); + source = new EventSource(url); + source.onopen = () => { attempt = 0; }; + source.onmessage = (message) => { + try { + onEvent(JSON.parse(message.data) as PlaybackSyncEvent); + } catch { + // A malformed frame is not worth tearing the stream down for. + } + }; + source.onerror = () => { + // CONNECTING means EventSource is already retrying on its own terms. + if (closed || source?.readyState !== EventSource.CLOSED) return; + source.close(); + source = null; + const wait = STREAM_REOPEN_MS[Math.min(attempt, STREAM_REOPEN_MS.length - 1)]; + attempt += 1; + timer = setTimeout(open, wait); + }; + }; + + const reopenIfDead = () => { + if (closed || document.visibilityState === 'hidden') return; + if (!source || source.readyState === EventSource.CLOSED) open(); + }; + + open(); + window.addEventListener('online', reopenIfDead); + document.addEventListener('visibilitychange', reopenIfDead); + + return () => { + closed = true; + clearTimeout(timer); + window.removeEventListener('online', reopenIfDead); + document.removeEventListener('visibilitychange', reopenIfDead); + source?.close(); }; - return () => source.close(); }, };