From 4ead344aec313dca29160928cf3f5e022b3433cc Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 8 Aug 2026 19:12:26 +0400 Subject: [PATCH] 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 --- backend/src/app.ts | 14 + backend/src/db/migrations.ts | 32 ++ backend/src/routes/playback.routes.ts | 208 +++++++++++ .../services/playback-sync.service.test.ts | 120 +++++++ backend/src/services/playback-sync.service.ts | 325 ++++++++++++++++++ frontend/nginx.conf.template | 14 + frontend/src/components/AppShell.tsx | 3 + frontend/src/components/CommandPalette.tsx | 4 +- frontend/src/components/DevicePicker.tsx | 83 +++++ frontend/src/components/PlaybackBar.tsx | 23 +- .../src/components/PlaybackSyncProvider.tsx | 20 ++ frontend/src/hooks/usePlaybackSync.ts | 221 ++++++++++++ frontend/src/services/playbackSync.ts | 132 +++++++ 13 files changed, 1194 insertions(+), 5 deletions(-) create mode 100644 backend/src/routes/playback.routes.ts create mode 100644 backend/src/services/playback-sync.service.test.ts create mode 100644 backend/src/services/playback-sync.service.ts create mode 100644 frontend/src/components/DevicePicker.tsx create mode 100644 frontend/src/components/PlaybackSyncProvider.tsx create mode 100644 frontend/src/hooks/usePlaybackSync.ts create mode 100644 frontend/src/services/playbackSync.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index a90385c..119d00d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); } diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index 1794695..b03c6c0 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -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() + ); + `, + }, ]; diff --git a/backend/src/routes/playback.routes.ts b/backend/src/routes/playback.routes.ts new file mode 100644 index 0000000..e143b96 --- /dev/null +++ b/backend/src/routes/playback.routes.ts @@ -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; + +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(() => {}); + }); + }); +} diff --git a/backend/src/services/playback-sync.service.test.ts b/backend/src/services/playback-sync.service.test.ts new file mode 100644 index 0000000..69b0852 --- /dev/null +++ b/backend/src/services/playback-sync.service.test.ts @@ -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); + }); +}); diff --git a/backend/src/services/playback-sync.service.ts b/backend/src/services/playback-sync.service.ts new file mode 100644 index 0000000..6a19059 --- /dev/null +++ b/backend/src/services/playback-sync.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const res = await this.pgPool.query( + `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 { + 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( + `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>; + 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 { + 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( + `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 { + 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 { + 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; + } +} diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index b1d9cf6..415dd9b 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -20,6 +20,20 @@ server { proxy_cache_bypass $http_upgrade; } + # The cross-device event stream is long-lived and must arrive unbuffered: + # with proxy buffering on, nginx holds each event until it has a chunk worth + # forwarding, which is exactly the latency this channel exists to avoid. + location /api/playback/stream { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization "Bearer ${MUZICK_API_KEY}"; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + } + location /api { proxy_pass http://backend:3000; proxy_http_version 1.1; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0b50f7d..013d05b 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -9,6 +9,7 @@ import { LyricsOverlay } from './LyricsOverlay'; import { Toaster } from './Toaster'; import { CommandPalette } from './CommandPalette'; import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard'; +import { PlaybackSyncProvider } from './PlaybackSyncProvider'; export default function AppShell() { const [queueOpen, setQueueOpen] = useState(false); @@ -47,6 +48,7 @@ export default function AppShell() { }); return ( +
setPaletteOpen(false)} />
+
); } diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index f2d8f33..e172e8f 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -140,7 +140,9 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { aria-hidden="true" /> {/* Dialog */} -
+ {/* Centred with margins, not a translate: animate-rise sets its own + transform and would drop a -translate-x-1/2 on the same element. */} +
{/* Search input */}
diff --git a/frontend/src/components/DevicePicker.tsx b/frontend/src/components/DevicePicker.tsx new file mode 100644 index 0000000..005fd9f --- /dev/null +++ b/frontend/src/components/DevicePicker.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from 'react'; +import { Laptop, MonitorSpeaker, Smartphone } from 'lucide-react'; +import { usePlaybackSyncContext } from './PlaybackSyncProvider'; + +/** + * Device menu. Picking a device moves the audio there: the chosen browser + * resumes the same track at the same position, and the one that had it stops. + */ +export function DevicePicker() { + const { devices, deviceId, isOwner, hasRemoteOwner, transferTo } = usePlaybackSyncContext(); + const [open, setOpen] = useState(false); + const wrapper = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!wrapper.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, [open]); + + const online = devices.filter((device) => device.online || device.id === deviceId); + // Nothing to switch between, so the control would only take up room. + if (online.length < 2 && !hasRemoteOwner) return null; + + const owner = devices.find((device) => device.isOwner) ?? null; + + return ( +
+ + + {open && ( +
+
+ Play on +
+ {online.map((device) => { + const isThis = device.id === deviceId; + return ( + + ); + })} + {!isOwner && !hasRemoteOwner && ( +
+ Press play to take over the session. +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/PlaybackBar.tsx b/frontend/src/components/PlaybackBar.tsx index a498105..5b63289 100644 --- a/frontend/src/components/PlaybackBar.tsx +++ b/frontend/src/components/PlaybackBar.tsx @@ -5,6 +5,8 @@ import { useDislikeTrack } from '../hooks/useDislikeTrack'; import { Artwork } from './Artwork'; import { ArtistLinks } from './ArtistLinks'; import { formatDuration } from './TrackRow'; +import { DevicePicker } from './DevicePicker'; +import { usePlaybackSyncContext } from './PlaybackSyncProvider'; interface PlaybackBarProps { queueOpen: boolean; @@ -16,6 +18,18 @@ interface PlaybackBarProps { export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) { const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, play, pause, next, prev, setPosition, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore(); const dislikeTrack = useDislikeTrack(); + const { hasRemoteOwner, sendCommand } = usePlaybackSyncContext(); + + // While another device holds the audio, the transport is a remote: the press + // travels to that device instead of starting a second stream here. + const remote = { + play: () => (hasRemoteOwner ? void sendCommand({ type: 'play' }) : play()), + pause: () => (hasRemoteOwner ? void sendCommand({ type: 'pause' }) : pause()), + next: () => (hasRemoteOwner ? void sendCommand({ type: 'next' }) : next()), + prev: () => (hasRemoteOwner ? void sendCommand({ type: 'prev' }) : prev()), + seek: (seconds: number) => + hasRemoteOwner ? void sendCommand({ type: 'seek', position: seconds }) : setPosition(seconds), + }; const handleDislike = () => { if (!currentTrack) return; @@ -80,18 +94,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri > - -