fix(playback): stop a moment without signal from pausing the phone
A phone changing cell or locking its screen drops the push stream for a few seconds while its audio keeps playing. The server released session ownership the instant that stream closed, and the phone read its own reconnect snapshot as another device taking over: it paused, and rewound to whatever position it had last reported. Ownership now survives a closed stream. A device that is really gone still loses the session, via pagehide and via the sweep that frees an owner whose heartbeat stopped. The client no longer treats an unowned session as an instruction to stop. With audio loaded it claims the session back instead. Two ways a phone could go quiet until the page was reloaded are also gone: registration is retried rather than attempted once, and the push stream reopens after an error status, which EventSource treats as final. It also checks itself when the network or the tab comes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
This commit is contained in:
@@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof setTimeout> | 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]);
|
||||
|
||||
@@ -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<typeof setTimeout> | 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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user