fix(sync): let a phone pause the desktop, and let a Vibe follow the audio
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

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>
This commit is contained in:
kami
2026-08-10 15:04:03 +04:00
parent a75d36b821
commit 57120b872d
17 changed files with 623 additions and 42 deletions
+10
View File
@@ -41,6 +41,12 @@ interface PlaybackState {
vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null;
/** Vibe must opt in explicitly; ordinary browsing always owns itself. */
queueOwner: PlaybackOwner;
/**
* True while another device holds the audio and this one is only showing what
* it plays. The engine loads no stream in that state, so a phone watching the
* desktop stops pulling megabytes of audio it will never play.
*/
audioElsewhere: boolean;
setQueue: (queue: Track[]) => void;
/** Vibe-only queue replacement. Do not use for library browsing. */
@@ -60,6 +66,7 @@ interface PlaybackState {
setPrefetchNext: (prefetchNext: boolean) => void;
setCrossfadeMs: (crossfadeMs: number) => void;
setCurrentTrack: (track: Track | null) => void;
setAudioElsewhere: (audioElsewhere: boolean) => void;
toggleShuffle: () => void;
cycleRepeat: () => void;
}
@@ -106,6 +113,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
shufflePlayed: new Set<string>(),
vibeAdvanceHandler: null,
queueOwner: 'ordinary',
audioElsewhere: false,
setQueue: (queue) =>
set((state) => ({
@@ -273,6 +281,8 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
currentIndex: currentTrack ? state.queue.findIndex((t) => t.id === currentTrack.id) : -1,
})),
setAudioElsewhere: (audioElsewhere) => set({ audioElsewhere }),
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
cycleRepeat: () =>
set((state) => {