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
+16
View File
@@ -876,6 +876,22 @@ export const MIGRATIONS: Migration[] = [
DO NOTHING;
`,
},
{
// Vibe control has to follow the audio. The running session's id rides with
// the playback snapshot, so the device taking the audio can adopt the
// session instead of walking whatever queue it happened to receive — which
// is what used to happen, silently, every time a listener moved the audio to
// their phone mid-Vibe.
//
// No foreign key on purpose: a report from the owning device carries the
// whole session, and losing that to a session row that has since been
// deleted would cost far more than a dangling id.
id: '20260810_playback_state_vibe_session',
sql: `
ALTER TABLE playback_state
ADD COLUMN IF NOT EXISTS vibe_session_id UUID;
`,
},
{
id: '20260810_rehide_resurrected_dislikes',
sql: `
+28 -5
View File
@@ -33,6 +33,12 @@ function parsePatch(body: unknown): PlaybackStatePatch | { error: string } {
}
patch.trackId = input.trackId as string | null;
}
if (input.vibeSessionId !== undefined) {
if (input.vibeSessionId !== null && !(typeof input.vibeSessionId === 'string' && UUID_RE.test(input.vibeSessionId))) {
return { error: 'vibeSessionId must be a UUID or null' };
}
patch.vibeSessionId = input.vibeSessionId as string | null;
}
if (input.queue !== undefined) {
if (!Array.isArray(input.queue)) return { error: 'queue must be an array' };
patch.queue = input.queue;
@@ -169,6 +175,12 @@ export default async function playbackRoutes(
const deviceId = typeof query.deviceId === 'string' && UUID_RE.test(query.deviceId) ? query.deviceId : null;
if (!deviceId) return reply.code(400).send({ error: 'deviceId must be a UUID' });
// The response is written straight to the socket and lives for as long as
// the tab does. Without this Fastify still believes it owes a reply and
// waits on a handler that resolves with nothing.
reply.hijack();
const releaseStream = playbackSync.claimStream(userId, deviceId);
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
@@ -186,11 +198,21 @@ export default async function playbackRoutes(
write(event);
});
const [state, devices] = await Promise.all([
playbackSync.getState(userId),
playbackSync.listDevices(userId),
]);
write({ type: 'state', state, devices });
// The reply is hijacked, so a throw from here reaches no error handler that
// could answer it. Close the stream instead and let the client reopen.
try {
const [state, devices] = await Promise.all([
playbackSync.getState(userId),
playbackSync.listDevices(userId),
]);
write({ type: 'state', state, devices });
} catch (err) {
request.log.error(err);
unsubscribe();
releaseStream();
reply.raw.end();
return;
}
const heartbeat = setInterval(() => {
reply.raw.write(': ping\n\n');
@@ -200,6 +222,7 @@ export default async function playbackRoutes(
request.raw.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
releaseStream();
// 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
@@ -9,6 +9,7 @@ import {
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const DESKTOP = '11111111-1111-4111-8111-111111111111';
const PHONE = '22222222-2222-4222-8222-222222222222';
const SESSION = '33333333-3333-4333-8333-333333333333';
/**
* A pool stubbed down to the one row this service reasons about. The queries
@@ -18,12 +19,14 @@ const PHONE = '22222222-2222-4222-8222-222222222222';
function poolWith(state: {
deviceId?: string | null;
trackId?: string | null;
vibeSessionId?: string | null;
isPlaying?: boolean;
positionMs?: number;
}) {
const row = {
device_id: state.deviceId ?? null,
track_id: state.trackId ?? null,
vibe_session_id: state.vibeSessionId ?? null,
queue: [],
queue_index: -1,
position_ms: state.positionMs ?? 0,
@@ -40,6 +43,12 @@ function poolWith(state: {
if (sql.includes('FROM playback_devices') && sql.includes('ORDER BY')) {
return { rows: [{ id: DESKTOP, name: 'Linux · Firefox', last_seen_at: row.updated_at, online: true }], rowCount: 1 };
}
if (sql.startsWith('UPDATE playback_devices SET name')) {
return { rows: [{ id: params?.[0], name: params?.[2], last_seen_at: row.updated_at }], rowCount: 1 };
}
if (sql.startsWith('INSERT INTO playback_devices')) {
return { rows: [{ id: PHONE, name: params?.[1], last_seen_at: row.updated_at }], rowCount: 1 };
}
if (sql.startsWith('SELECT device_id FROM playback_state')) {
return { rows: [{ device_id: row.device_id }], rowCount: 1 };
}
@@ -76,11 +85,40 @@ describe('cross-device playback', () => {
expect(state.deviceId).toBe(PHONE);
});
it('publishes the Vibe session the owning device is driving', async () => {
// Vibe control follows the audio, and this is how the next owner hears about
// the session it is taking over.
const { pool } = poolWith({ deviceId: DESKTOP, vibeSessionId: SESSION });
const service = new PlaybackSyncService(pool);
expect((await service.getState(USER_ID)).vibeSessionId).toBe(SESSION);
});
it('keeps a device its stored id across a reload', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
expect(device.id).toBe(DESKTOP);
});
it('gives a second tab a device of its own', async () => {
// Both tabs of a browser ask with the same stored id, and one id shared by
// two pages is one device that runs every command twice.
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
service.claimStream(USER_ID, DESKTOP);
const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
expect(device.id).toBe(PHONE);
});
it('delivers a command to the owning device only', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
const seen: PlaybackEvent[] = [];
service.subscribe(USER_ID, (event) => seen.push(event));
service.claimStream(USER_ID, DESKTOP);
const result = await service.sendCommand(USER_ID, { type: 'pause' });
@@ -88,6 +126,30 @@ describe('cross-device playback', () => {
expect(seen).toContainEqual({ type: 'command', deviceId: DESKTOP, command: { type: 'pause' } });
});
it('reports no delivery when the owning device has no stream to receive on', async () => {
// Ownership outlives a closed stream, so a killed tab still holds the
// session. Answering 202 there told the presser a lie.
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
expect(await service.sendCommand(USER_ID, { type: 'pause' })).toEqual({ deliveredTo: null });
});
it('frees a device id once its stream closes, and not before', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
const release = service.claimStream(USER_ID, DESKTOP);
const alsoOpen = service.claimStream(USER_ID, DESKTOP);
release();
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(true);
alsoOpen();
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
// A close arriving twice must not free an id a later stream is holding.
release();
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
});
it('reports no delivery when nothing holds the audio', async () => {
const { pool } = poolWith({ deviceId: null });
const service = new PlaybackSyncService(pool);
+58 -5
View File
@@ -43,6 +43,12 @@ export interface PlaybackDevice {
export interface PlaybackSnapshot {
deviceId: string | null;
trackId: string | null;
/**
* The durable Vibe session the owning device is playing, when it is playing
* one. Whichever device holds the audio drives the session, so this is how the
* next owner learns there is one to adopt.
*/
vibeSessionId: string | null;
queue: unknown[];
queueIndex: number;
position: number;
@@ -53,6 +59,7 @@ export interface PlaybackSnapshot {
export interface PlaybackStatePatch {
trackId?: string | null;
vibeSessionId?: string | null;
queue?: unknown[];
queueIndex?: number;
position?: number;
@@ -73,6 +80,7 @@ export class NotSessionOwnerError extends Error {
type StateRow = {
device_id: string | null;
track_id: string | null;
vibe_session_id: string | null;
queue: unknown[];
queue_index: number;
position_ms: number;
@@ -85,6 +93,7 @@ function toSnapshot(row: StateRow): PlaybackSnapshot {
return {
deviceId: row.device_id,
trackId: row.track_id,
vibeSessionId: row.vibe_session_id ?? null,
queue: Array.isArray(row.queue) ? row.queue : [],
queueIndex: row.queue_index,
position: row.position_ms / 1000,
@@ -101,6 +110,15 @@ function clampPositionMs(position: number | undefined, fallback: number): number
export class PlaybackSyncService {
private readonly emitter = new EventEmitter();
/**
* Open streams per device, keyed `userId:deviceId`. A device only exists as
* far as this session is concerned while it is holding one: that is what
* decides whether a command can be delivered, and whether a browser asking to
* reuse a stored device id would be colliding with a tab that already has it.
* Counted rather than a flag, so a reconnect racing its own close cannot leave
* a device permanently marked busy.
*/
private readonly streams = new Map<string, number>();
constructor(private readonly pgPool: Pool) {
// One session with many idle tabs is the normal case, and Node warns at ten
@@ -113,6 +131,24 @@ export class PlaybackSyncService {
return () => this.emitter.off(userId, listener);
}
/** Mark a device's stream open until the returned function is called. */
claimStream(userId: string, deviceId: string): () => void {
const key = `${userId}:${deviceId}`;
this.streams.set(key, (this.streams.get(key) ?? 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
const open = (this.streams.get(key) ?? 1) - 1;
if (open > 0) this.streams.set(key, open);
else this.streams.delete(key);
};
}
hasLiveStream(userId: string, deviceId: string): boolean {
return (this.streams.get(`${userId}:${deviceId}`) ?? 0) > 0;
}
private async publish(userId: string): Promise<void> {
const [state, devices] = await Promise.all([this.getState(userId), this.listDevices(userId)]);
this.emitter.emit(userId, { type: 'state', state, devices } satisfies PlaybackEvent);
@@ -122,10 +158,15 @@ export class PlaybackSyncService {
* Register a device, or refresh one the browser already knows about. The
* caller supplies the id it stored locally so a reload keeps its identity and
* the device list does not grow one row per page load.
*
* A second tab of the same browser asks with the same stored id, and two tabs
* sharing one device id are one device that runs every command twice and plays
* two copies of the audio. An id whose stream is already open therefore does
* not get reused: the caller is given a device of its own instead.
*/
async registerDevice(userId: string, name: string, deviceId?: string | null): Promise<PlaybackDevice> {
const cleanName = (name || 'Unknown device').trim().slice(0, 120) || 'Unknown device';
if (deviceId) {
if (deviceId && !this.hasLiveStream(userId, deviceId)) {
const updated = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
`UPDATE playback_devices SET name = $3, last_seen_at = NOW()
WHERE id = $1 AND user_id = $2
@@ -197,7 +238,7 @@ export class PlaybackSyncService {
const res = await this.pgPool.query<StateRow>(
`INSERT INTO playback_state (user_id) VALUES ($1)
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
RETURNING device_id, track_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
[userId]
);
return toSnapshot(res.rows[0]);
@@ -227,10 +268,15 @@ export class PlaybackSyncService {
queue_index = COALESCE($6, queue_index),
position_ms = COALESCE($7, position_ms),
is_playing = COALESCE($8, is_playing),
-- Only the device driving the Vibe reports one, and it reports the
-- absence of one just as explicitly: ending a Vibe and playing an
-- album has to clear this, or the next owner would adopt a session
-- nothing is playing any more.
vibe_session_id = COALESCE($9, CASE WHEN $10 THEN NULL ELSE vibe_session_id END),
version = version + 1,
updated_at = NOW()
WHERE user_id = $1
RETURNING device_id, track_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
[
userId,
deviceId,
@@ -240,6 +286,8 @@ export class PlaybackSyncService {
patch.queueIndex ?? null,
patch.position === undefined ? null : clampPositionMs(patch.position, 0),
patch.isPlaying ?? null,
patch.vibeSessionId ?? null,
patch.vibeSessionId === null,
]
);
@@ -259,10 +307,15 @@ export class PlaybackSyncService {
* Aim a command at whichever device holds the audio. Any device may send one,
* including the owner itself — that is what makes a phone a remote for the
* desktop without either side knowing which is which.
*
* Ownership outlives a closed stream, so the owner is not necessarily
* listening: a tab that was killed keeps the session until the sweep frees it.
* Emitting into that gap answered the presser with a success it did not get,
* so a command is only accepted while the owner has a stream to receive it on.
*/
async sendCommand(userId: string, command: PlaybackCommand): Promise<{ deliveredTo: string | null }> {
const owner = await this.ownerId(userId);
if (!owner) return { deliveredTo: null };
if (!owner || !this.hasLiveStream(userId, owner)) return { deliveredTo: null };
this.emitter.emit(userId, { type: 'command', deviceId: owner, command } satisfies PlaybackEvent);
return { deliveredTo: owner };
}
@@ -283,7 +336,7 @@ export class PlaybackSyncService {
`UPDATE playback_state
SET device_id = $2, version = version + 1, updated_at = NOW()
WHERE user_id = $1
RETURNING device_id, track_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
[userId, deviceId]
);
await this.publish(userId);
+20 -2
View File
@@ -1,4 +1,4 @@
import { render, waitFor } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
@@ -28,7 +28,7 @@ describe('AudioEngine', () => {
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
usePlaybackStore.setState({
currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, audioElsewhere: false,
});
});
@@ -42,6 +42,24 @@ describe('AudioEngine', () => {
Object.defineProperty(audio, 'ended', { value: false, configurable: true });
};
it('loads no stream while another device holds the audio, and loads one when it comes back', () => {
usePlaybackStore.setState({
audioElsewhere: true, isPlaying: false, prefetchNext: true,
queue: [track('song'), track('next')], currentIndex: 0,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
expect(active.getAttribute('src')).toBeNull();
expect(idle.getAttribute('src')).toBeNull();
// Mirroring is not playback: this device reports nothing to the session.
expect(reportVibeEvent).not.toHaveBeenCalled();
act(() => usePlaybackStore.getState().setAudioElsewhere(false));
expect(active.src).toContain('/tracks/song/stream');
});
it('buffers the next queued track into the idle element before the current one ends', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
+27 -1
View File
@@ -213,7 +213,7 @@ export const AudioEngine = () => {
/** Warm the predicted next track into the idle element. */
const prefetch = () => {
const playback = store();
if (!playback.prefetchNext) return;
if (!playback.prefetchNext || playback.audioElsewhere) return;
const nextId = predictNextTrackId();
if (!nextId || nextId === loadedIdRef.current) return;
const idleIdx = 1 - activeIdxRef.current;
@@ -352,6 +352,20 @@ export const AudioEngine = () => {
if (els.length === 0) return;
const applyTrack = (id: string | null) => {
// Another device is playing and this one is only showing what it plays.
// Pointing an element at the stream here would download the track — the
// whole of it, once an earlier prefetch left that element on preload=auto
// — for audio that is never heard. Drop the sources and follow along.
if (usePlaybackStore.getState().audioElsewhere) {
cancelJoin();
for (const idx of [0, 1]) retire(idx);
loadedIdRef.current = null;
preparedRef.current = null;
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
earlyAdvanceRef.current = false;
return;
}
if (id === loadedIdRef.current) return;
// A newer track change supersedes any tail still waiting to hand over.
cancelJoin();
@@ -424,8 +438,15 @@ export const AudioEngine = () => {
};
// Apply the current value immediately, then subscribe to future changes.
let lastElsewhere = usePlaybackStore.getState().audioElsewhere;
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
const unsub = usePlaybackStore.subscribe((state) => {
if (state.audioElsewhere !== lastElsewhere) {
lastElsewhere = state.audioElsewhere;
// The audio just came back to this device. Nothing is loaded, and the
// track id has not changed, so say so and let applyTrack load it.
if (!lastElsewhere) loadedIdRef.current = null;
}
applyTrack(state.currentTrack?.id ?? null);
});
return unsub;
@@ -438,6 +459,8 @@ export const AudioEngine = () => {
const apply = (isPlaying: boolean) => {
const audio = els[activeIdxRef.current];
// Nothing is loaded while another device holds the audio.
if (usePlaybackStore.getState().audioElsewhere) return;
if (isPlaying) {
// Never resume a finished element. Between `ended` and the next source
// being loaded, isPlaying is still true, and resuming here replays the
@@ -488,6 +511,9 @@ export const AudioEngine = () => {
const apply = (position: number) => {
const audio = els[activeIdxRef.current];
// A mirrored position belongs to another device's playhead, and there is
// no source loaded here to move anyway.
if (usePlaybackStore.getState().audioElsewhere) return;
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
audio.currentTime = position;
}
+3 -3
View File
@@ -35,7 +35,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
previous?.focus();
};
}, []);
const { currentTrack, queue, isPlaying, position, duration } = usePlaybackStore();
const { currentTrack, queue, position, duration } = usePlaybackStore();
const transport = useTransport();
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
@@ -110,11 +110,11 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
<button onClick={transport.prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
<button
onClick={transport.toggle}
aria-label={isPlaying ? 'Pause' : 'Play'}
aria-label={transport.playing ? 'Pause' : 'Play'}
disabled={!currentTrack}
className="transport-btn"
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={transport.next} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipForward size={20} /></button>
</div>
+3 -3
View File
@@ -16,7 +16,7 @@ interface PlaybackBarProps {
}
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
const { currentTrack, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
const dislikeTrack = useDislikeTrack();
const transport = useTransport();
@@ -90,9 +90,9 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
onClick={transport.toggle}
disabled={!currentTrack}
className="transport-btn"
aria-label={isPlaying ? 'Pause' : 'Play'}
aria-label={transport.playing ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={transport.next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
<SkipForward size={20} />
+100 -1
View File
@@ -1,6 +1,7 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { playbackSyncService, type PlaybackSnapshot, type PlaybackSyncEvent } from '../services/playbackSync';
import { usePlaybackSync } from './usePlaybackSync';
@@ -9,6 +10,12 @@ const OTHER_DEVICE = '22222222-2222-2222-2222-222222222222';
let emit: ((event: PlaybackSyncEvent) => void) | null = null;
const { adoptVibeSession, releaseVibeDriving } = vi.hoisted(() => ({
adoptVibeSession: vi.fn(async () => true),
releaseVibeDriving: vi.fn(),
}));
vi.mock('../services/vibeSession', () => ({ adoptVibeSession, releaseVibeDriving }));
vi.mock('../services/playbackSync', () => ({
storedDeviceId: () => null,
playbackSyncService: {
@@ -34,6 +41,7 @@ function snapshot(over: Partial<PlaybackSnapshot> = {}): PlaybackSnapshot {
return {
deviceId: THIS_DEVICE,
trackId: null,
vibeSessionId: null,
queue: [],
queueIndex: 0,
position: 0,
@@ -58,7 +66,11 @@ async function mountOwning() {
describe('usePlaybackSync', () => {
beforeEach(() => {
emit = null;
usePlaybackStore.setState({ position: 0, isPlaying: false, queue: [], currentTrack: null });
useVibeStore.getState().reset();
usePlaybackStore.setState({
position: 0, isPlaying: false, queue: [], currentTrack: null, audioElsewhere: false,
queueOwner: 'ordinary', vibeAdvanceHandler: null,
});
});
it('takes the reported position when it first gains the session', async () => {
@@ -95,6 +107,93 @@ describe('usePlaybackSync', () => {
expect(view.result.current.hasRemoteOwner).toBe(true);
});
it('reports the remote play state rather than its own while it watches', async () => {
const view = await mountOwning();
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, isPlaying: true, version: 3 }),
devices: [],
});
});
expect(view.result.current.remotePlaying).toBe(true);
expect(usePlaybackStore.getState().isPlaying).toBe(false);
expect(usePlaybackStore.getState().audioElsewhere).toBe(true);
});
it('reports the Vibe session it is driving so the next device can take it', async () => {
await mountOwning();
act(() => {
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
});
await waitFor(() => expect(playbackSyncService.reportState).toHaveBeenCalledWith(
THIS_DEVICE,
expect.objectContaining({ vibeSessionId: 'session-a' }),
));
});
it('stops driving the Vibe when the audio moves away, and adopts it back', async () => {
const view = await mountOwning();
act(() => {
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
usePlaybackStore.getState().setVibeAdvanceHandler(() => undefined);
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
});
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: 'session-a', version: 3 }),
devices: [],
});
});
// One driver at a time, and it is whichever device holds the audio.
expect(releaseVibeDriving).toHaveBeenCalled();
expect(adoptVibeSession).not.toHaveBeenCalled();
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: 'session-a', version: 4 }),
devices: [],
});
});
expect(view.result.current.isOwner).toBe(true);
expect(usePlaybackStore.getState().audioElsewhere).toBe(false);
expect(adoptVibeSession).toHaveBeenCalledWith('session-a');
});
it('lets go of its own Vibe when it takes over playback that is not one', async () => {
await mountOwning();
act(() => {
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
});
// The other device played an album, and this one is taking that over.
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: null, version: 3 }),
devices: [],
});
});
await act(async () => {
emit!({ type: 'state', state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: null, version: 4 }), devices: [] });
});
expect(adoptVibeSession).not.toHaveBeenCalled();
expect(useVibeStore.getState().activeSessionId).toBeNull();
});
it('keeps playing and reclaims the session when a dropped stream leaves it unowned', async () => {
await mountOwning();
act(() => usePlaybackStore.setState({
+59 -4
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import {
PlaybackCommand,
PlaybackDevice,
@@ -8,6 +9,7 @@ import {
storedDeviceId,
} from '../services/playbackSync';
import { trackService } from '../services/trackService';
import { adoptVibeSession, releaseVibeDriving } from '../services/vibeSession';
import type { Track } from '../types';
/**
@@ -33,6 +35,12 @@ export interface PlaybackSyncApi {
isOwner: boolean;
/** True once another device holds the audio, so controls become remote controls. */
hasRemoteOwner: boolean;
/**
* Whether the device holding the audio is playing. The local `isPlaying` says
* nothing about it — a watching device is always paused — so controls that
* draw a play/pause state read this one while `hasRemoteOwner` is true.
*/
remotePlaying: boolean;
transferTo: (deviceId: string) => Promise<void>;
sendCommand: (command: PlaybackCommand) => Promise<void>;
}
@@ -41,6 +49,7 @@ export function usePlaybackSync(): PlaybackSyncApi {
const [deviceId, setDeviceId] = useState<string | null>(storedDeviceId());
const [devices, setDevices] = useState<PlaybackDevice[]>([]);
const [ownerId, setOwnerId] = useState<string | null>(null);
const [remotePlaying, setRemotePlaying] = useState(false);
const deviceIdRef = useRef<string | null>(deviceId);
const ownerIdRef = useRef<string | null>(null);
@@ -57,9 +66,13 @@ export function usePlaybackSync(): PlaybackSyncApi {
const id = deviceIdRef.current;
if (!id) return;
const store = usePlaybackStore.getState();
const vibe = useVibeStore.getState();
try {
await playbackSyncService.reportState(id, {
trackId: store.currentTrack?.id ?? null,
// Reported by the device driving the Vibe, and reported as null by a
// device playing anything else, so the next owner knows which it is.
vibeSessionId: store.queueOwner === 'vibe' ? vibe.activeSessionId : null,
queue: store.queue,
queueIndex: store.currentIndex,
position: store.position,
@@ -71,6 +84,22 @@ export function usePlaybackSync(): PlaybackSyncApi {
}
}, []);
/**
* Take the queue off the wire without ending a Vibe this device is running.
* `setQueue` is an ownership handoff: it drops the advance handler, which is
* the thing that asks the server for the next Vibe track. A snapshot from
* another device used to go through it, so mirroring the desktop — or taking
* the audio back afterwards — turned a live session into a static list of the
* hundred tracks that happened to be synced.
*/
const adoptQueue = useCallback((queue: Track[]) => {
if (queue.length === 0) return;
const store = usePlaybackStore.getState();
const runningVibe = store.queueOwner === 'vibe' && useVibeStore.getState().activeSessionId !== null;
if (runningVibe) store.setVibeQueue(queue);
else store.setQueue(queue);
}, []);
const applySnapshot = useCallback(async (state: PlaybackSnapshot) => {
if (state.version <= lastVersion.current) return;
lastVersion.current = state.version;
@@ -81,6 +110,13 @@ export function usePlaybackSync(): PlaybackSyncApi {
const alreadyOwnedIt = ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current;
setOwnerId(state.deviceId);
// What the controls draw, and whether the engine should load anything: both
// follow from where the audio is, so they are set before any of the writes
// below and on every snapshot, including the ones the owner ignores.
const elsewhere = state.deviceId !== null && !iOwnIt;
setRemotePlaying(elsewhere ? state.isPlaying : false);
store.setAudioElsewhere(elsewhere);
// The server publishes a snapshot for anything that touches the session,
// including another device merely registering on page load. For the device
// already holding the audio those snapshots carry nothing new: the position
@@ -103,9 +139,11 @@ export function usePlaybackSync(): PlaybackSyncApi {
applyingRemote.current = true;
try {
if (!iOwnIt) {
// Another device holds the audio. Show what it plays, make no sound.
// Another device holds the audio, and with it the Vibe. Stop driving the
// session from here, then show what it plays and make no sound.
if (store.isPlaying) store.pause();
if (state.queue.length > 0) store.setQueue(state.queue);
if (store.vibeAdvanceHandler) releaseVibeDriving();
adoptQueue(state.queue);
const shown = state.queue[state.queueIndex] ?? store.currentTrack;
if (shown && shown.id !== store.currentTrack?.id) store.setCurrentTrack(shown);
store.setPosition(state.position);
@@ -114,7 +152,7 @@ export function usePlaybackSync(): PlaybackSyncApi {
// We just took the session over: rebuild the queue, land on the right
// track, and resume from where the previous device actually was.
if (state.queue.length > 0) store.setQueue(state.queue);
adoptQueue(state.queue);
let track: Track | null = state.queue[state.queueIndex] ?? null;
if (!track && state.trackId && state.trackId !== store.currentTrack?.id) {
track = await trackService.getTrack(state.trackId).catch(() => null);
@@ -126,7 +164,20 @@ export function usePlaybackSync(): PlaybackSyncApi {
} finally {
applyingRemote.current = false;
}
}, [reportNow]);
// Only a device that just took the audio reaches this: every other case
// returned above. Vibe control travels with the audio, so pick up the
// session the previous owner was driving — or let go of the one this device
// still holds, because whatever is playing now is not it.
if (state.vibeSessionId) {
// Adoption rewrites the unplayed queue from the durable plan, and the
// others are showing this device's queue, so tell them.
if (await adoptVibeSession(state.vibeSessionId).catch(() => false)) void reportNow();
} else if (useVibeStore.getState().activeSessionId) {
releaseVibeDriving();
useVibeStore.getState().reset();
}
}, [adoptQueue, reportNow]);
const applyCommand = useCallback(async (command: PlaybackCommand) => {
const store = usePlaybackStore.getState();
@@ -186,6 +237,9 @@ export function usePlaybackSync(): PlaybackSyncApi {
cancelled = true;
clearTimeout(retry);
closeStream?.();
// Nothing is watching the session any more, so nothing knows where the
// audio is. Leaving the flag set would keep the engine silent for good.
usePlaybackStore.getState().setAudioElsewhere(false);
};
}, [applySnapshot, applyCommand]);
@@ -256,6 +310,7 @@ export function usePlaybackSync(): PlaybackSyncApi {
devices,
isOwner: ownerId !== null && ownerId === deviceId,
hasRemoteOwner: ownerId !== null && ownerId !== deviceId,
remotePlaying,
transferTo,
sendCommand,
};
+22 -4
View File
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { PlaybackSyncContext } from '../components/PlaybackSyncProvider';
import type { PlaybackSyncApi } from './usePlaybackSync';
import type { PlaybackCommand } from '../services/playbackSync';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useTransport } from './useTransport';
@@ -12,6 +13,7 @@ function wrapperFor(api: Partial<PlaybackSyncApi>) {
devices: [],
isOwner: false,
hasRemoteOwner: false,
remotePlaying: false,
transferTo: async () => undefined,
sendCommand: async () => undefined,
...api,
@@ -36,12 +38,16 @@ describe('useTransport', () => {
});
it('forwards the press when another device holds the audio', () => {
usePlaybackStore.setState({ isPlaying: true });
const sendCommand = vi.fn();
// A watching device keeps its own audio paused, so what the button does
// follows the remote state and never the local one.
usePlaybackStore.setState({ isPlaying: false });
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
const { result } = renderHook(() => useTransport(), {
wrapper: wrapperFor({ hasRemoteOwner: true, sendCommand }),
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: true, sendCommand }),
});
expect(result.current.playing).toBe(true);
act(() => {
result.current.toggle();
result.current.next();
@@ -54,7 +60,19 @@ describe('useTransport', () => {
{ type: 'seek', position: 30 },
]);
// The remote device is the one that stops; this one never started.
expect(usePlaybackStore.getState().isPlaying).toBe(true);
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
it('asks a paused remote device to play, whatever this device was doing', () => {
usePlaybackStore.setState({ isPlaying: true });
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
const { result } = renderHook(() => useTransport(), {
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: false, sendCommand }),
});
act(() => result.current.toggle());
expect(sendCommand).toHaveBeenCalledWith({ type: 'play' });
});
it('plays here when there is no sync session at all', () => {
+24 -8
View File
@@ -19,30 +19,46 @@ export interface Transport {
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 ? void sendCommand!({ type: 'play' }) : local().play()),
pause: () => (away ? void sendCommand!({ type: 'pause' }) : local().pause()),
play: () => (away ? send({ type: 'play' }) : local().play()),
pause: () => (away ? send({ type: 'pause' }) : local().pause()),
toggle: () => {
const playing = local().isPlaying;
if (away) void sendCommand!({ type: playing ? 'pause' : 'play' });
if (away) send({ type: playing ? 'pause' : 'play' });
else if (playing) local().pause();
else local().play();
},
next: () => (away ? void sendCommand!({ type: 'next' }) : local().next()),
prev: () => (away ? void sendCommand!({ type: 'prev' }) : local().prev()),
next: () => (away ? send({ type: 'next' }) : local().next()),
prev: () => (away ? send({ type: 'prev' }) : local().prev()),
seek: (seconds: number) =>
away ? void sendCommand!({ type: 'seek', position: seconds }) : local().setPosition(seconds),
away ? send({ type: 'seek', position: seconds }) : local().setPosition(seconds),
remote: away,
playing,
};
}, [hasRemoteOwner, sendCommand]);
}, [hasRemoteOwner, playing, sendCommand]);
}
+28 -1
View File
@@ -28,6 +28,8 @@ export interface PlaybackDevice {
export interface PlaybackSnapshot {
deviceId: string | null;
trackId: string | null;
/** The Vibe session the owning device is driving, if it is driving one. */
vibeSessionId: string | null;
queue: Track[];
queueIndex: number;
position: number;
@@ -40,9 +42,19 @@ export type PlaybackSyncEvent =
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
| { type: 'command'; deviceId: string; command: PlaybackCommand };
/**
* The id this tab registered under, or the one the browser last used.
*
* Two levels, because a device is really a tab and not a browser: the audio
* element, the queue and the command handling all live in one page. Two tabs
* sharing a single id are one device that runs every command twice, so each tab
* keeps its own id in `sessionStorage` — which survives its reloads and nothing
* else. The `localStorage` copy is only a seed for a tab that has no id yet, and
* the server refuses to hand it back while another tab's stream is holding it.
*/
export function storedDeviceId(): string | null {
try {
return localStorage.getItem(DEVICE_ID_KEY);
return sessionStorage.getItem(DEVICE_ID_KEY) ?? localStorage.getItem(DEVICE_ID_KEY);
} catch {
return null;
}
@@ -50,6 +62,7 @@ export function storedDeviceId(): string | null {
function rememberDeviceId(id: string): void {
try {
sessionStorage.setItem(DEVICE_ID_KEY, id);
localStorage.setItem(DEVICE_ID_KEY, id);
} catch {
// Private browsing: the device still works, it just re-registers next load.
@@ -95,6 +108,7 @@ export const playbackSyncService = {
/** Report what this device is playing. Rejected with 409 once it is not the owner. */
async reportState(deviceId: string, patch: {
trackId?: string | null;
vibeSessionId?: string | null;
queue?: Track[];
queueIndex?: number;
position?: number;
@@ -162,14 +176,27 @@ export const playbackSyncService = {
if (!source || source.readyState === EventSource.CLOSED) open();
};
// React never unmounts on a page leaving, so the stream would stay open
// until the server noticed the socket die. Closing it here frees the device
// id straight away, which is what lets a reload register as the same device
// rather than being told it is a second tab. A page that comes back from the
// history cache reopens through `reopenIfDead`.
const closeForNow = () => {
clearTimeout(timer);
source?.close();
source = null;
};
open();
window.addEventListener('online', reopenIfDead);
window.addEventListener('pagehide', closeForNow);
document.addEventListener('visibilitychange', reopenIfDead);
return () => {
closed = true;
clearTimeout(timer);
window.removeEventListener('online', reopenIfDead);
window.removeEventListener('pagehide', closeForNow);
document.removeEventListener('visibilitychange', reopenIfDead);
source?.close();
};
+12
View File
@@ -18,6 +18,8 @@ export interface VibePlanItem {
export interface DurableVibeSessionResponse {
sessionId: string;
/** The durable session row. Present on start, resume and plan reads. */
session?: { id: string; seed_track_id: string | null };
planVersion: number | null;
now: VibePlanItem | null;
preview: VibePlanItem[];
@@ -71,6 +73,16 @@ export const vibeService = {
return res.data;
},
/**
* Pick up a session that is already running, on a device that did not start
* it. Resuming is how Vibe control follows the audio between devices; it also
* revives a session the reaper had paused.
*/
async resume(sessionId: string): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { resumeSessionId: sessionId });
return res.data;
},
async getPlan(sessionId: string, version?: number): Promise<DurableVibeSessionResponse> {
const res = await api.get<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/plans`, {
params: version === undefined ? undefined : { version },
+77 -5
View File
@@ -4,13 +4,21 @@ import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
const { start, resume, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
start: vi.fn(), resume: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
}));
vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } }));
vi.mock('./vibeService', () => ({ vibeService: { start, resume, next, advancePastUnplayable, event, end } }));
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
import {
adoptVibeSession,
advancePastUnplayableVibeTrack,
advanceVibe,
endVibeSession,
releaseVibeDriving,
reportVibeEvent,
startVibeSession,
} from './vibeSession';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
@@ -23,7 +31,7 @@ const item = (track_id: string, committed = false, ordinal = 0) => ({
score: 1, score_breakdown: {}, explanation: [], committed,
});
const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({
const response = (planVersion: number, now: ReturnType<typeof item> | null = item('one', true), preview = [item('two')]) => ({
sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
});
@@ -349,6 +357,70 @@ describe('durable Vibe session client', () => {
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
});
it('adopts a session running on another device and drives it from here', async () => {
// What a device that has just been handed the audio starts from: the queue
// and playing track came off the playback snapshot, the session did not.
usePlaybackStore.setState({
currentTrack: track('one'), queue: [track('one')], currentIndex: 0, isPlaying: true,
});
resume.mockResolvedValue({ ...response(3, null, [item('two'), item('three')]), session: { id: 'session-a', seed_track_id: 'one' } });
await expect(adoptVibeSession('session-a')).resolves.toBe(true);
expect(resume).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState()).toMatchObject({
activeSessionId: 'session-a', seedTrackId: 'one', planVersion: 3, buffer: [track('two'), track('three')],
});
// No cursor is recoverable for the track already playing; the next advance sets one.
expect(useVibeStore.getState().currentPlanItem).toBeNull();
expect(usePlaybackStore.getState().queueOwner).toBe('vibe');
expect(usePlaybackStore.getState().vibeAdvanceHandler).not.toBeNull();
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
// The track that was playing keeps playing: adoption replaces the future only.
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
});
it('keeps playing the ordinary queue when the session it was told about is gone', async () => {
usePlaybackStore.setState({ currentTrack: track('one'), queue: [track('one')], currentIndex: 0 });
resume.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
}));
await expect(adoptVibeSession('session-a')).resolves.toBe(false);
expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', vibeAdvanceHandler: null });
expect(useVibeStore.getState().activeSessionId).toBeNull();
});
it('stops driving a session without ending it when the audio moves away', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('one'));
releaseVibeDriving();
expect(usePlaybackStore.getState().vibeAdvanceHandler).toBeNull();
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
expect(end).not.toHaveBeenCalled();
});
it('reports an unplayable stream as a skip while an adopted session has no cursor', async () => {
usePlaybackStore.setState({
currentTrack: track('one'), queue: [track('one')], currentIndex: 0,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
});
resume.mockResolvedValue(response(3, null, [item('two')]));
await adoptVibeSession('session-a');
event.mockResolvedValue(response(4, null, [item('two')]));
next.mockResolvedValue(response(4, item('two', true), []));
await advancePastUnplayableVibeTrack('one');
expect(advancePastUnplayable).not.toHaveBeenCalled();
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
});
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
+74
View File
@@ -482,6 +482,16 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
*/
export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
if (advanceInFlight) return advanceInFlight;
// A session adopted from another device has no cursor until its first advance.
// Without one there is no durable item to step past, so report the failure as
// an ordinary skip rather than stalling on a track that will not play.
const adopted = useVibeStore.getState();
if (
adopted.activeSessionId && !adopted.currentPlanItem && seedPendingTrackId !== trackId
&& usePlaybackStore.getState().currentTrack?.id === trackId
) {
return advanceVibe('skipped');
}
advanceInFlight = serializeMaterial(async () => {
const vibe = useVibeStore.getState();
const playback = usePlaybackStore.getState();
@@ -572,6 +582,70 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
}
}
/**
* Take over a session that is already running, on the device that has just been
* given the audio.
*
* A Vibe has two halves: the durable plan on the server, and the controller here
* that reports outcomes and asks for the next track. Only one device may hold
* the controller, and the right one is whichever holds the audio — otherwise the
* device that started the session keeps replanning for a player it cannot hear,
* or, as it did before this existed, nobody replans at all and the Vibe quietly
* becomes a fixed list of whatever tracks were synced.
*
* The queue and playing track are already in place from the playback snapshot;
* this restores the session around them.
*/
export async function adoptVibeSession(sessionId: string): Promise<boolean> {
return serializeMaterial(async () => {
const playback = usePlaybackStore.getState();
const alreadyDriving = useVibeStore.getState().activeSessionId === sessionId
&& playback.queueOwner === 'vibe'
&& playback.vibeAdvanceHandler !== null;
if (alreadyDriving) return true;
let response: DurableVibeSessionResponse;
try {
response = await vibeService.resume(sessionId);
} catch (error) {
// Ended, replaced or simply gone: there is nothing to drive, and the
// ordinary queue this device received is the honest thing to keep playing.
if (isSessionTerminalError(error)) return false;
throw error;
}
if (!response.planVersion) return false;
const preview = await hydratePreview(response.preview);
const vibe = useVibeStore.getState();
// A different session, or a stale local revision of this one: drop it before
// admitting the revision the server just reported.
if (vibe.activeSessionId !== sessionId) vibe.reset();
vibe.setActiveSession({ sessionId, seedTrackId: response.session?.seed_track_id ?? null });
vibe.setPlan(response.planVersion, preview);
vibe.setProfile(response.state);
// The cursor for the track already playing was committed in a revision that
// a later replan superseded, so it is not in this response and cannot be
// reconstructed. The first advance sets it; until then a stream that fails
// is reported as an ordinary skip.
vibe.setCurrentPlanItem(null);
// Ownership first: replaceUnplayedQueue and every session guard read it.
usePlaybackStore.getState().setVibeQueue(usePlaybackStore.getState().queue);
installVibeAdvanceHandler();
replaceUnplayedQueue(preview);
return true;
});
}
/**
* Give up driving the session without ending it, because the audio has moved to
* another device. The session stays on record here so the local Vibe view still
* has something to show, and taking the audio back adopts it again.
*/
export function releaseVibeDriving(): void {
usePlaybackStore.getState().setVibeAdvanceHandler(null);
}
export async function endVibeSession(): Promise<void> {
return serializeMaterial(async () => {
const sessionId = useVibeStore.getState().activeSessionId;
+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) => {