feat(playback): move a session between devices

One device holds the audio; the rest watch the same session over an
event stream and act as remotes. Picking a device hands the audio over
at the position the previous one reported, and that device stops.

Also centre the command palette with margins instead of a translate:
animate-rise sets its own transform and dropped the offset on mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-08 19:12:26 +04:00
parent bfe22745bc
commit 4ead344aec
13 changed files with 1194 additions and 5 deletions
+14
View File
@@ -19,6 +19,8 @@ import discoveryRoutes from './routes/discovery.routes.js';
import imagesRoutes from './routes/images.routes.js';
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
import { DiscoveryService } from './services/discovery.service.js';
import { PlaybackSyncService } from './services/playback-sync.service.js';
import playbackRoutes from './routes/playback.routes.js';
// Single-listener deployment: the same placeholder the vibe-session routes use
// when no x-user-id header is supplied.
@@ -124,6 +126,16 @@ export async function buildApp(config: AppConfig) {
runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e));
}, DISCOVERY_EVAL_INTERVAL_MS);
// Cross-device playback. A device that dies without closing its stream — a
// phone going to sleep, a laptop lid — leaves the session owned by something
// that will never play again, so a sweep frees ownership once its heartbeat
// has been silent past the staleness window.
const playbackSync = new PlaybackSyncService(pgPool);
const DEVICE_REAP_INTERVAL_MS = 60 * 1000;
const deviceReapTimer = setInterval(() => {
playbackSync.reapStaleDevices().catch((e) => console.error('[Playback] device reap failed:', e));
}, DEVICE_REAP_INTERVAL_MS);
// Ensure the Typesense 'tracks' collection schema exists on boot so that
// the first search request doesn't hit a 404.
await searchService.ensureCollection();
@@ -199,6 +211,7 @@ export async function buildApp(config: AppConfig) {
coordinator: vibeSessionCoordinator,
});
fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
fastify.register(playbackRoutes, { prefix: '/api', playbackSync });
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
// nothing in the deployed app or its tests called it, and deployment never
// sets NODE_ENV so an env gate would've stayed live in prod anyway.
@@ -210,6 +223,7 @@ export async function buildApp(config: AppConfig) {
clearInterval(decayTimer);
clearInterval(forgottenTimer);
clearInterval(discoveryEvalTimer);
clearInterval(deviceReapTimer);
} catch (err) {
fastify.log.error(err);
}
+32
View File
@@ -819,4 +819,36 @@ export const MIGRATIONS: Migration[] = [
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
`,
},
{
// One listener, many browsers. `playback_state` is the single authority for
// what is playing and which device owns the audio, so a phone can take over
// from a desktop mid-track. The queue is stored as whole track objects, not
// ids: the device taking over needs to render the queue immediately, and a
// snapshot of what was queued at handoff time is the honest thing to move.
id: '20260808_playback_devices',
sql: `
CREATE TABLE IF NOT EXISTS playback_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name TEXT NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_playback_devices_user
ON playback_devices (user_id, last_seen_at DESC);
CREATE TABLE IF NOT EXISTS playback_state (
user_id UUID PRIMARY KEY,
device_id UUID REFERENCES playback_devices(id) ON DELETE SET NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
queue JSONB NOT NULL DEFAULT '[]'::jsonb,
queue_index INTEGER NOT NULL DEFAULT -1,
position_ms INTEGER NOT NULL DEFAULT 0,
is_playing BOOLEAN NOT NULL DEFAULT FALSE,
-- Monotonic per user. A device applies a snapshot only when it is newer
-- than the last one it saw, so a delayed delivery cannot rewind anyone.
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`,
},
];
+208
View File
@@ -0,0 +1,208 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import {
NotSessionOwnerError,
PlaybackCommand,
PlaybackCommandType,
PlaybackStatePatch,
PlaybackSyncService,
} from '../services/playback-sync.service.js';
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const COMMAND_TYPES: PlaybackCommandType[] = ['play', 'pause', 'next', 'prev', 'seek', 'play_track'];
/** Well inside the 90s staleness window, and enough to keep proxies from closing an idle stream. */
const HEARTBEAT_MS = 25_000;
type Body = Record<string, unknown>;
function isObject(value: unknown): value is Body {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function userIdFrom(request: FastifyRequest): string {
const header = request.headers['x-user-id'];
return typeof header === 'string' && UUID_RE.test(header) ? header : DEFAULT_USER_ID;
}
function parsePatch(body: unknown): PlaybackStatePatch | { error: string } {
const input = isObject(body) ? body : {};
const patch: PlaybackStatePatch = {};
if (input.trackId !== undefined) {
if (input.trackId !== null && !(typeof input.trackId === 'string' && UUID_RE.test(input.trackId))) {
return { error: 'trackId must be a UUID or null' };
}
patch.trackId = input.trackId as string | null;
}
if (input.queue !== undefined) {
if (!Array.isArray(input.queue)) return { error: 'queue must be an array' };
patch.queue = input.queue;
}
if (input.queueIndex !== undefined) {
if (typeof input.queueIndex !== 'number' || !Number.isInteger(input.queueIndex)) {
return { error: 'queueIndex must be an integer' };
}
patch.queueIndex = input.queueIndex;
}
if (input.position !== undefined) {
if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
return { error: 'position must be a non-negative number of seconds' };
}
patch.position = input.position;
}
if (input.isPlaying !== undefined) {
if (typeof input.isPlaying !== 'boolean') return { error: 'isPlaying must be a boolean' };
patch.isPlaying = input.isPlaying;
}
return patch;
}
function parseCommand(body: unknown): PlaybackCommand | { error: string } {
const input = isObject(body) ? body : {};
const type = input.type;
if (typeof type !== 'string' || !COMMAND_TYPES.includes(type as PlaybackCommandType)) {
return { error: `type must be one of ${COMMAND_TYPES.join(', ')}` };
}
const command: PlaybackCommand = { type: type as PlaybackCommandType };
if (type === 'seek') {
if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
return { error: 'seek requires a non-negative position in seconds' };
}
command.position = input.position;
}
if (type === 'play_track') {
if (typeof input.trackId !== 'string' || !UUID_RE.test(input.trackId)) {
return { error: 'play_track requires a trackId' };
}
command.trackId = input.trackId;
}
return command;
}
export default async function playbackRoutes(
fastify: FastifyInstance,
options: { playbackSync: PlaybackSyncService }
) {
const { playbackSync } = options;
fastify.post('/playback/devices', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const name = typeof input.name === 'string' ? input.name : '';
const deviceId = typeof input.deviceId === 'string' && UUID_RE.test(input.deviceId) ? input.deviceId : null;
const device = await playbackSync.registerDevice(userIdFrom(request), name, deviceId);
return reply.code(200).send(device);
});
fastify.get('/playback/devices', async (request, reply) => {
return reply.code(200).send({ devices: await playbackSync.listDevices(userIdFrom(request)) });
});
fastify.get('/playback/state', async (request, reply) => {
const userId = userIdFrom(request);
const [state, devices] = await Promise.all([
playbackSync.getState(userId),
playbackSync.listDevices(userId),
]);
return reply.code(200).send({ state, devices });
});
fastify.post('/playback/state', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const deviceId = input.deviceId;
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
return reply.code(400).send({ error: 'deviceId must be a UUID' });
}
const patch = parsePatch(input);
if ('error' in patch) return reply.code(400).send({ error: patch.error });
try {
const state = await playbackSync.reportState(userIdFrom(request), deviceId, patch);
return reply.code(200).send({ state });
} catch (err) {
if (err instanceof NotSessionOwnerError) {
// 409, not 403: the device is allowed here, it is simply no longer the
// one holding the audio, and its own state report is the stale thing.
return reply.code(409).send({ error: err.message });
}
throw err;
}
});
fastify.post('/playback/command', async (request, reply) => {
const command = parseCommand(request.body);
if ('error' in command) return reply.code(400).send({ error: command.error });
const result = await playbackSync.sendCommand(userIdFrom(request), command);
if (!result.deliveredTo) return reply.code(409).send({ error: 'no device is holding playback' });
return reply.code(202).send(result);
});
fastify.post('/playback/transfer', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const deviceId = input.deviceId;
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
return reply.code(400).send({ error: 'deviceId must be a UUID' });
}
try {
return reply.code(200).send({ state: await playbackSync.transfer(userIdFrom(request), deviceId) });
} catch {
return reply.code(404).send({ error: 'unknown device' });
}
});
fastify.post('/playback/release', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const deviceId = input.deviceId;
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
return reply.code(400).send({ error: 'deviceId must be a UUID' });
}
await playbackSync.releaseIfOwner(userIdFrom(request), deviceId);
return reply.code(204).send();
});
/**
* The push channel. Every device holds one of these open: it receives the
* session snapshot on connect, every later change, and the commands aimed at
* it. The periodic comment line doubles as the device's liveness heartbeat,
* so an open stream is what "this device is online" means.
*/
fastify.get('/playback/stream', async (request, reply) => {
const userId = userIdFrom(request);
const query = request.query as { deviceId?: string };
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' });
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// nginx buffers event streams into uselessness without this.
'X-Accel-Buffering': 'no',
});
const write = (payload: unknown) => {
reply.raw.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const unsubscribe = playbackSync.subscribe(userId, (event) => {
if (event.type === 'command' && event.deviceId !== deviceId) return;
write(event);
});
const [state, devices] = await Promise.all([
playbackSync.getState(userId),
playbackSync.listDevices(userId),
]);
write({ type: 'state', state, devices });
const heartbeat = setInterval(() => {
reply.raw.write(': ping\n\n');
playbackSync.touchDevice(userId, deviceId).catch(() => {});
}, HEARTBEAT_MS);
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(() => {});
});
});
}
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import type { Pool } from 'pg';
import {
NotSessionOwnerError,
PlaybackEvent,
PlaybackSyncService,
} from './playback-sync.service.js';
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const DESKTOP = '11111111-1111-4111-8111-111111111111';
const PHONE = '22222222-2222-4222-8222-222222222222';
/**
* A pool stubbed down to the one row this service reasons about. The queries
* themselves are plain SQL against a single table, so what is worth testing is
* the ownership and fan-out logic sitting on top of them.
*/
function poolWith(state: {
deviceId?: string | null;
trackId?: string | null;
isPlaying?: boolean;
positionMs?: number;
}) {
const row = {
device_id: state.deviceId ?? null,
track_id: state.trackId ?? null,
queue: [],
queue_index: -1,
position_ms: state.positionMs ?? 0,
is_playing: state.isPlaying ?? false,
version: '4',
updated_at: new Date('2026-08-08T12:00:00Z'),
};
const queries: string[] = [];
const pool = {
async query(rawSql: string, params?: unknown[]) {
queries.push(rawSql);
// The service formats its SQL in columns, so match on collapsed text.
const sql = rawSql.replace(/\s+/g, ' ').trim();
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('SELECT device_id FROM playback_state')) {
return { rows: [{ device_id: row.device_id }], rowCount: 1 };
}
if (sql.includes('SELECT id FROM playback_devices')) {
return { rows: [{ id: params?.[0] }], rowCount: 1 };
}
if (sql.includes('UPDATE playback_state') && sql.includes('device_id = $2')) {
row.device_id = String(params?.[1]);
return { rows: [row], rowCount: 1 };
}
if (sql.includes('UPDATE playback_state')) {
return { rows: [row], rowCount: 1 };
}
return { rows: [row], rowCount: 1 };
},
} as unknown as Pool;
return { pool, queries, row };
}
describe('cross-device playback', () => {
it('refuses a state report from a device that no longer holds the audio', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
await expect(service.reportState(USER_ID, PHONE, { isPlaying: true }))
.rejects.toBeInstanceOf(NotSessionOwnerError);
});
it('accepts the first device to report on an unowned session', async () => {
const { pool } = poolWith({ deviceId: null });
const service = new PlaybackSyncService(pool);
const state = await service.reportState(USER_ID, PHONE, { isPlaying: true });
expect(state.deviceId).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));
const result = await service.sendCommand(USER_ID, { type: 'pause' });
expect(result.deliveredTo).toBe(DESKTOP);
expect(seen).toContainEqual({ type: 'command', deviceId: DESKTOP, command: { type: 'pause' } });
});
it('reports no delivery when nothing holds the audio', async () => {
const { pool } = poolWith({ deviceId: null });
const service = new PlaybackSyncService(pool);
expect(await service.sendCommand(USER_ID, { type: 'play' })).toEqual({ deliveredTo: null });
});
it('hands the session to another device without losing the position', async () => {
const { pool } = poolWith({ deviceId: DESKTOP, trackId: 'track-1', positionMs: 45_000, isPlaying: true });
const service = new PlaybackSyncService(pool);
const seen: PlaybackEvent[] = [];
service.subscribe(USER_ID, (event) => seen.push(event));
const state = await service.transfer(USER_ID, PHONE);
expect(state.deviceId).toBe(PHONE);
expect(state.position).toBe(45);
expect(state.isPlaying).toBe(true);
expect(seen.some((event) => event.type === 'state')).toBe(true);
});
it('frees ownership when the owning device goes away', async () => {
const { pool, queries } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
await service.releaseIfOwner(USER_ID, DESKTOP);
expect(queries.some((sql) => sql.includes('SET device_id = NULL'))).toBe(true);
});
});
@@ -0,0 +1,325 @@
import { EventEmitter } from 'node:events';
import type { Pool } from 'pg';
/**
* Cross-device playback: one authoritative session per listener, any number of
* devices watching it, exactly one of them holding the audio.
*
* Two channels do the work. `state` carries the snapshot every device renders,
* so a phone that just opened shows what the desktop is playing. `command`
* carries an instruction aimed at the owning device only, because the audio
* element lives there and nowhere else. Handoff is a state change like any
* other: the new owner starts at the position the old one reported, and the old
* one stops when it sees it no longer owns the session.
*
* ponytail: the fan-out is an in-process EventEmitter, which is correct for the
* single backend container this deploys as. A second instance would need Redis
* pub/sub here — the rest of the design already assumes nothing else.
*/
/** A device is offline once it stops sending its stream heartbeat. */
export const DEVICE_STALE_MS = 90_000;
/** Queue snapshots are capped: this is a handoff payload, not a playlist store. */
export const MAX_SYNCED_QUEUE = 100;
export type PlaybackCommandType = 'play' | 'pause' | 'next' | 'prev' | 'seek' | 'play_track';
export interface PlaybackCommand {
type: PlaybackCommandType;
/** Seconds into the current track. Only meaningful for `seek`. */
position?: number;
/** Only meaningful for `play_track`. */
trackId?: string;
}
export interface PlaybackDevice {
id: string;
name: string;
lastSeenAt: string;
online: boolean;
isOwner: boolean;
}
export interface PlaybackSnapshot {
deviceId: string | null;
trackId: string | null;
queue: unknown[];
queueIndex: number;
position: number;
isPlaying: boolean;
version: number;
updatedAt: string;
}
export interface PlaybackStatePatch {
trackId?: string | null;
queue?: unknown[];
queueIndex?: number;
position?: number;
isPlaying?: boolean;
}
export type PlaybackEvent =
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
| { type: 'command'; deviceId: string; command: PlaybackCommand };
export class NotSessionOwnerError extends Error {
constructor() {
super('device does not own playback');
this.name = 'NotSessionOwnerError';
}
}
type StateRow = {
device_id: string | null;
track_id: string | null;
queue: unknown[];
queue_index: number;
position_ms: number;
is_playing: boolean;
version: string;
updated_at: Date;
};
function toSnapshot(row: StateRow): PlaybackSnapshot {
return {
deviceId: row.device_id,
trackId: row.track_id,
queue: Array.isArray(row.queue) ? row.queue : [],
queueIndex: row.queue_index,
position: row.position_ms / 1000,
isPlaying: row.is_playing,
version: Number(row.version),
updatedAt: row.updated_at.toISOString(),
};
}
function clampPositionMs(position: number | undefined, fallback: number): number {
if (typeof position !== 'number' || !Number.isFinite(position) || position < 0) return fallback;
return Math.min(Math.round(position * 1000), 24 * 60 * 60 * 1000);
}
export class PlaybackSyncService {
private readonly emitter = new EventEmitter();
constructor(private readonly pgPool: Pool) {
// One session with many idle tabs is the normal case, and Node warns at ten
// listeners on the assumption they are a leak. They are not.
this.emitter.setMaxListeners(0);
}
subscribe(userId: string, listener: (event: PlaybackEvent) => void): () => void {
this.emitter.on(userId, listener);
return () => this.emitter.off(userId, listener);
}
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);
}
/**
* 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.
*/
async registerDevice(userId: string, name: string, deviceId?: string | null): Promise<PlaybackDevice> {
const cleanName = (name || 'Unknown device').trim().slice(0, 120) || 'Unknown device';
if (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
RETURNING id, name, last_seen_at`,
[deviceId, userId, cleanName]
);
if (updated.rows[0]) {
const owner = await this.ownerId(userId);
await this.publish(userId);
return {
id: updated.rows[0].id,
name: updated.rows[0].name,
lastSeenAt: updated.rows[0].last_seen_at.toISOString(),
online: true,
isOwner: owner === updated.rows[0].id,
};
}
}
const inserted = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
`INSERT INTO playback_devices (user_id, name) VALUES ($1, $2)
RETURNING id, name, last_seen_at`,
[userId, cleanName]
);
const row = inserted.rows[0];
await this.publish(userId);
return {
id: row.id,
name: row.name,
lastSeenAt: row.last_seen_at.toISOString(),
online: true,
isOwner: false,
};
}
async touchDevice(userId: string, deviceId: string): Promise<void> {
await this.pgPool.query(
`UPDATE playback_devices SET last_seen_at = NOW() WHERE id = $1 AND user_id = $2`,
[deviceId, userId]
);
}
async listDevices(userId: string): Promise<PlaybackDevice[]> {
const owner = await this.ownerId(userId);
const res = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date; online: boolean }>(
`SELECT id, name, last_seen_at,
last_seen_at > NOW() - ($2::int * INTERVAL '1 millisecond') AS online
FROM playback_devices
WHERE user_id = $1
ORDER BY last_seen_at DESC`,
[userId, DEVICE_STALE_MS]
);
return res.rows.map((row) => ({
id: row.id,
name: row.name,
lastSeenAt: row.last_seen_at.toISOString(),
online: row.online,
isOwner: row.id === owner,
}));
}
private async ownerId(userId: string): Promise<string | null> {
const res = await this.pgPool.query<{ device_id: string | null }>(
'SELECT device_id FROM playback_state WHERE user_id = $1', [userId]
);
return res.rows[0]?.device_id ?? null;
}
async getState(userId: string): Promise<PlaybackSnapshot> {
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`,
[userId]
);
return toSnapshot(res.rows[0]);
}
/**
* Record what the owning device is doing. A device that does not own the
* session is rejected rather than ignored, so a stale tab resuming from sleep
* learns it lost the audio instead of silently fighting the current owner.
*/
async reportState(userId: string, deviceId: string, patch: PlaybackStatePatch): Promise<PlaybackSnapshot> {
const current = await this.getState(userId);
if (current.deviceId !== null && current.deviceId !== deviceId) throw new NotSessionOwnerError();
const queue = patch.queue === undefined
? undefined
: patch.queue.slice(0, MAX_SYNCED_QUEUE);
// A track deleted between the device reading it and reporting it would
// otherwise fail the foreign key and take the whole report down with it.
// The session is worth more than the pointer: keep the rest, drop the id.
const write = async (trackId: string | null) => this.pgPool.query<StateRow>(
`UPDATE playback_state
SET device_id = $2,
track_id = COALESCE($3, CASE WHEN $4 THEN NULL ELSE track_id END),
queue = COALESCE($5::jsonb, queue),
queue_index = COALESCE($6, queue_index),
position_ms = COALESCE($7, position_ms),
is_playing = COALESCE($8, is_playing),
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`,
[
userId,
deviceId,
trackId,
patch.trackId === null,
queue === undefined ? null : JSON.stringify(queue),
patch.queueIndex ?? null,
patch.position === undefined ? null : clampPositionMs(patch.position, 0),
patch.isPlaying ?? null,
]
);
let res: Awaited<ReturnType<typeof write>>;
try {
res = await write(patch.trackId ?? null);
} catch (err) {
if ((err as { code?: string }).code !== '23503') throw err;
res = await write(null);
}
await this.touchDevice(userId, deviceId);
await this.publish(userId);
return toSnapshot(res.rows[0]);
}
/**
* 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.
*/
async sendCommand(userId: string, command: PlaybackCommand): Promise<{ deliveredTo: string | null }> {
const owner = await this.ownerId(userId);
if (!owner) return { deliveredTo: null };
this.emitter.emit(userId, { type: 'command', deviceId: owner, command } satisfies PlaybackEvent);
return { deliveredTo: owner };
}
/**
* Move the audio to `deviceId`. The snapshot is untouched apart from the
* owner, so the new device resumes the same track at the same position, and
* the previous owner stops as soon as the state event reaches it.
*/
async transfer(userId: string, deviceId: string): Promise<PlaybackSnapshot> {
await this.getState(userId);
const device = await this.pgPool.query(
'SELECT id FROM playback_devices WHERE id = $1 AND user_id = $2', [deviceId, userId]
);
if (device.rows.length === 0) throw new Error('unknown device');
const res = await this.pgPool.query<StateRow>(
`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`,
[userId, deviceId]
);
await this.publish(userId);
return toSnapshot(res.rows[0]);
}
/**
* Release ownership when the owning device goes away, leaving the snapshot
* intact so another device can pick the session up where it stopped.
*/
async releaseIfOwner(userId: string, deviceId: string): Promise<void> {
const res = await this.pgPool.query(
`UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
version = version + 1, updated_at = NOW()
WHERE user_id = $1 AND device_id = $2`,
[userId, deviceId]
);
if (res.rowCount) await this.publish(userId);
}
/** Drop devices that have not been seen for a day, and free a stale owner. */
async reapStaleDevices(): Promise<number> {
const stale = await this.pgPool.query<{ user_id: string }>(
`UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
version = version + 1, updated_at = NOW()
WHERE device_id IN (
SELECT id FROM playback_devices
WHERE last_seen_at < NOW() - ($1::int * INTERVAL '1 millisecond')
)
RETURNING user_id`,
[DEVICE_STALE_MS]
);
const removed = await this.pgPool.query(
`DELETE FROM playback_devices WHERE last_seen_at < NOW() - INTERVAL '1 day'`
);
for (const row of stale.rows) await this.publish(row.user_id);
return removed.rowCount ?? 0;
}
}