57120b872d
Six bugs in the single transport and in how a browser registers as a device, then the feature the fourth one was hiding. A watching device keeps its own audio paused, so every control that read `isPlaying` from the store drew a Play button while the desktop played — and sent `play` when it was pressed. The transport now carries one `playing` value: the remote state while presses are being forwarded, the local one otherwise. Two tabs of one browser shared a stored device id, which made them one device that ran every command twice and played two copies of the audio. A device id is now held by whichever stream has it open: registration refuses to hand back a busy id, and each tab keeps its own in `sessionStorage`. A device that was only showing what another one plays still pointed an audio element at the stream, downloading tracks it would never play. It now loads nothing while the audio is elsewhere, and reloads the moment it comes back. Commands were accepted for an owner with no stream to receive them on, so a killed tab answered a press with a success it never got. Ownership outlives a closed stream deliberately; delivery does not. The event stream never called `reply.hijack()`, leaving Fastify waiting on a handler that resolves with nothing. And the Vibe: `setQueue` is an ownership handoff, so a snapshot from another device dropped the advance handler that asks the server for the next track. A session moved to a phone became a fixed list of the hundred tracks that happened to be synced. Vibe control now follows the audio — the snapshot carries the session id, the device losing the audio stops driving, and the one gaining it resumes the durable session and takes over replanning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65 lines
2.7 KiB
TypeScript
65 lines
2.7 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { usePlaybackStore } from '../store/usePlaybackStore';
|
|
import { useOptionalPlaybackSync } from '../components/PlaybackSyncProvider';
|
|
|
|
/**
|
|
* The transport every control set should call.
|
|
*
|
|
* While another device holds the audio, a press has to travel to that device
|
|
* instead of starting a second stream here. Controls that reach into the
|
|
* playback store directly work on the desktop and silently do nothing useful
|
|
* from a phone, so there is one place that makes the choice.
|
|
*/
|
|
export interface Transport {
|
|
play: () => void;
|
|
pause: () => void;
|
|
toggle: () => void;
|
|
next: () => void;
|
|
prev: () => void;
|
|
seek: (seconds: number) => void;
|
|
/** True when the presses are being forwarded rather than played here. */
|
|
remote: boolean;
|
|
/**
|
|
* Whether the audio is playing, wherever it is. A watching device holds its
|
|
* own store paused, so a control that reads `isPlaying` from the store draws a
|
|
* Play button while the desktop plays — and then sends `play` when the
|
|
* listener presses it, which is why pausing from a phone never worked. Every
|
|
* control reads this instead.
|
|
*/
|
|
playing: boolean;
|
|
}
|
|
|
|
export function useTransport(): Transport {
|
|
const sync = useOptionalPlaybackSync();
|
|
const hasRemoteOwner = sync?.hasRemoteOwner ?? false;
|
|
const remotePlaying = sync?.remotePlaying ?? false;
|
|
const sendCommand = sync?.sendCommand;
|
|
const localPlaying = usePlaybackStore((state) => state.isPlaying);
|
|
const playing = hasRemoteOwner ? remotePlaying : localPlaying;
|
|
|
|
return useMemo(() => {
|
|
const local = () => usePlaybackStore.getState();
|
|
const away = hasRemoteOwner && sendCommand !== undefined;
|
|
// The owning device may hold the session without listening on it — a killed
|
|
// tab keeps it until the sweep. The backend answers that with a 409, and a
|
|
// rejected press is not worth an unhandled rejection.
|
|
const send = (command: Parameters<NonNullable<typeof sendCommand>>[0]) =>
|
|
void Promise.resolve(sendCommand!(command)).catch(() => undefined);
|
|
return {
|
|
play: () => (away ? send({ type: 'play' }) : local().play()),
|
|
pause: () => (away ? send({ type: 'pause' }) : local().pause()),
|
|
toggle: () => {
|
|
if (away) send({ type: playing ? 'pause' : 'play' });
|
|
else if (playing) local().pause();
|
|
else local().play();
|
|
},
|
|
next: () => (away ? send({ type: 'next' }) : local().next()),
|
|
prev: () => (away ? send({ type: 'prev' }) : local().prev()),
|
|
seek: (seconds: number) =>
|
|
away ? send({ type: 'seek', position: seconds }) : local().setPosition(seconds),
|
|
remote: away,
|
|
playing,
|
|
};
|
|
}, [hasRemoteOwner, playing, sendCommand]);
|
|
}
|