fix(sync): let a phone pause the desktop, and let a Vibe follow the audio
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:
@@ -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: `
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user