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:
@@ -19,6 +19,8 @@ import discoveryRoutes from './routes/discovery.routes.js';
|
|||||||
import imagesRoutes from './routes/images.routes.js';
|
import imagesRoutes from './routes/images.routes.js';
|
||||||
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
|
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
|
||||||
import { DiscoveryService } from './services/discovery.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
|
// Single-listener deployment: the same placeholder the vibe-session routes use
|
||||||
// when no x-user-id header is supplied.
|
// 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));
|
runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e));
|
||||||
}, DISCOVERY_EVAL_INTERVAL_MS);
|
}, 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
|
// Ensure the Typesense 'tracks' collection schema exists on boot so that
|
||||||
// the first search request doesn't hit a 404.
|
// the first search request doesn't hit a 404.
|
||||||
await searchService.ensureCollection();
|
await searchService.ensureCollection();
|
||||||
@@ -199,6 +211,7 @@ export async function buildApp(config: AppConfig) {
|
|||||||
coordinator: vibeSessionCoordinator,
|
coordinator: vibeSessionCoordinator,
|
||||||
});
|
});
|
||||||
fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
|
fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
|
||||||
|
fastify.register(playbackRoutes, { prefix: '/api', playbackSync });
|
||||||
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
|
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
|
||||||
// nothing in the deployed app or its tests called it, and deployment never
|
// 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.
|
// 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(decayTimer);
|
||||||
clearInterval(forgottenTimer);
|
clearInterval(forgottenTimer);
|
||||||
clearInterval(discoveryEvalTimer);
|
clearInterval(discoveryEvalTimer);
|
||||||
|
clearInterval(deviceReapTimer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -819,4 +819,36 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
|
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()
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,20 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
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 {
|
location /api {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LyricsOverlay } from './LyricsOverlay';
|
|||||||
import { Toaster } from './Toaster';
|
import { Toaster } from './Toaster';
|
||||||
import { CommandPalette } from './CommandPalette';
|
import { CommandPalette } from './CommandPalette';
|
||||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||||
|
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
@@ -47,6 +48,7 @@ export default function AppShell() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PlaybackSyncProvider>
|
||||||
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
|
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
|
||||||
<KeyboardListener />
|
<KeyboardListener />
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -72,5 +74,6 @@ export default function AppShell() {
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
</PlaybackSyncProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{/* Dialog */}
|
{/* Dialog */}
|
||||||
<div className="fixed left-1/2 top-[15vh] z-50 w-full max-w-lg -translate-x-1/2 animate-rise">
|
{/* Centred with margins, not a translate: animate-rise sets its own
|
||||||
|
transform and would drop a -translate-x-1/2 on the same element. */}
|
||||||
|
<div className="fixed inset-x-4 top-[15vh] z-50 mx-auto max-w-lg animate-rise">
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60">
|
<div className="overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={wrapper}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className={`rounded-md p-2 transition-colors ${
|
||||||
|
hasRemoteOwner ? 'bg-accent/20 text-accent' : 'text-muted hover:bg-surface0 hover:text-text'
|
||||||
|
}`}
|
||||||
|
aria-label="Playback device"
|
||||||
|
aria-expanded={open}
|
||||||
|
title={hasRemoteOwner && owner ? `Playing on ${owner.name}` : 'Playing on this device'}
|
||||||
|
>
|
||||||
|
<MonitorSpeaker size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute bottom-full right-0 z-50 mb-2 w-60 overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
Play on
|
||||||
|
</div>
|
||||||
|
{online.map((device) => {
|
||||||
|
const isThis = device.id === deviceId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={device.id}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
if (!device.isOwner) void transferTo(device.id);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-surface0 ${
|
||||||
|
device.isOwner ? 'text-accent' : 'text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/Android|iPhone|iPad/i.test(device.name) ? <Smartphone size={16} /> : <Laptop size={16} />}
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{device.name}
|
||||||
|
{isThis && <span className="text-muted"> · this device</span>}
|
||||||
|
</span>
|
||||||
|
{device.isOwner && <span className="text-xs text-accent">playing</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isOwner && !hasRemoteOwner && (
|
||||||
|
<div className="border-t border-border px-3 py-2 text-xs text-muted">
|
||||||
|
Press play to take over the session.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
|||||||
import { Artwork } from './Artwork';
|
import { Artwork } from './Artwork';
|
||||||
import { ArtistLinks } from './ArtistLinks';
|
import { ArtistLinks } from './ArtistLinks';
|
||||||
import { formatDuration } from './TrackRow';
|
import { formatDuration } from './TrackRow';
|
||||||
|
import { DevicePicker } from './DevicePicker';
|
||||||
|
import { usePlaybackSyncContext } from './PlaybackSyncProvider';
|
||||||
|
|
||||||
interface PlaybackBarProps {
|
interface PlaybackBarProps {
|
||||||
queueOpen: boolean;
|
queueOpen: boolean;
|
||||||
@@ -16,6 +18,18 @@ interface PlaybackBarProps {
|
|||||||
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: 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 { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, play, pause, next, prev, setPosition, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
||||||
const dislikeTrack = useDislikeTrack();
|
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 = () => {
|
const handleDislike = () => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
@@ -80,18 +94,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
|||||||
>
|
>
|
||||||
<Shuffle size={18} />
|
<Shuffle size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
|
<button onClick={remote.prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
|
||||||
<SkipBack size={20} />
|
<SkipBack size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => isPlaying ? pause() : play()}
|
onClick={() => (isPlaying ? remote.pause() : remote.play())}
|
||||||
disabled={!currentTrack}
|
disabled={!currentTrack}
|
||||||
className="transport-btn"
|
className="transport-btn"
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
|
<button onClick={remote.next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
|
||||||
<SkipForward size={20} />
|
<SkipForward size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -108,7 +122,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
|||||||
<input
|
<input
|
||||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||||
value={Math.min(position, duration || 0)}
|
value={Math.min(position, duration || 0)}
|
||||||
onChange={(e) => setPosition(Number(e.target.value))}
|
onChange={(e) => remote.seek(Number(e.target.value))}
|
||||||
disabled={!currentTrack || duration <= 0}
|
disabled={!currentTrack || duration <= 0}
|
||||||
className="flex-1 h-1 cursor-pointer"
|
className="flex-1 h-1 cursor-pointer"
|
||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
@@ -126,6 +140,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
|||||||
className="hidden w-20 h-1 cursor-pointer sm:block"
|
className="hidden w-20 h-1 cursor-pointer sm:block"
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
/>
|
/>
|
||||||
|
<DevicePicker />
|
||||||
<button
|
<button
|
||||||
onClick={onToggleLyrics}
|
onClick={onToggleLyrics}
|
||||||
disabled={!currentTrack}
|
disabled={!currentTrack}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { usePlaybackSync, type PlaybackSyncApi } from '../hooks/usePlaybackSync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sync session per app, shared by everything that draws a transport
|
||||||
|
* control. Mounting the hook twice would open two streams and register the same
|
||||||
|
* browser as two devices.
|
||||||
|
*/
|
||||||
|
const PlaybackSyncContext = createContext<PlaybackSyncApi | null>(null);
|
||||||
|
|
||||||
|
export function PlaybackSyncProvider({ children }: { children: ReactNode }) {
|
||||||
|
const sync = usePlaybackSync();
|
||||||
|
return <PlaybackSyncContext.Provider value={sync}>{children}</PlaybackSyncContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaybackSyncContext(): PlaybackSyncApi {
|
||||||
|
const value = useContext(PlaybackSyncContext);
|
||||||
|
if (!value) throw new Error('usePlaybackSyncContext must be used inside PlaybackSyncProvider');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||||
|
import {
|
||||||
|
PlaybackCommand,
|
||||||
|
PlaybackDevice,
|
||||||
|
PlaybackSnapshot,
|
||||||
|
playbackSyncService,
|
||||||
|
storedDeviceId,
|
||||||
|
} from '../services/playbackSync';
|
||||||
|
import { trackService } from '../services/trackService';
|
||||||
|
import type { Track } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps this browser in step with the listener's other devices.
|
||||||
|
*
|
||||||
|
* Exactly one device holds the audio. That device reports what it is playing;
|
||||||
|
* every other device renders the same thing and, when the listener presses a
|
||||||
|
* control, sends a command instead of playing locally. Picking a device from
|
||||||
|
* the menu moves the audio: the new owner resumes the same track at the
|
||||||
|
* position the old one last reported, and the old one stops when it sees the
|
||||||
|
* session is no longer its.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Position drifts constantly; anything faster than this is noise on the wire. */
|
||||||
|
const POSITION_REPORT_MS = 10_000;
|
||||||
|
|
||||||
|
export interface PlaybackSyncApi {
|
||||||
|
deviceId: string | null;
|
||||||
|
devices: PlaybackDevice[];
|
||||||
|
isOwner: boolean;
|
||||||
|
/** True once another device holds the audio, so controls become remote controls. */
|
||||||
|
hasRemoteOwner: boolean;
|
||||||
|
transferTo: (deviceId: string) => Promise<void>;
|
||||||
|
sendCommand: (command: PlaybackCommand) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaybackSync(): PlaybackSyncApi {
|
||||||
|
const [deviceId, setDeviceId] = useState<string | null>(storedDeviceId());
|
||||||
|
const [devices, setDevices] = useState<PlaybackDevice[]>([]);
|
||||||
|
const [ownerId, setOwnerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deviceIdRef = useRef<string | null>(deviceId);
|
||||||
|
const ownerIdRef = useRef<string | null>(null);
|
||||||
|
// Set while a remote snapshot or command is being written into the store, so
|
||||||
|
// the store subscription below does not report those changes straight back.
|
||||||
|
const applyingRemote = useRef(false);
|
||||||
|
const lastVersion = useRef(-1);
|
||||||
|
const lastPositionReport = useRef(0);
|
||||||
|
|
||||||
|
deviceIdRef.current = deviceId;
|
||||||
|
ownerIdRef.current = ownerId;
|
||||||
|
|
||||||
|
const reportNow = useCallback(async () => {
|
||||||
|
const id = deviceIdRef.current;
|
||||||
|
if (!id) return;
|
||||||
|
const store = usePlaybackStore.getState();
|
||||||
|
try {
|
||||||
|
await playbackSyncService.reportState(id, {
|
||||||
|
trackId: store.currentTrack?.id ?? null,
|
||||||
|
queue: store.queue,
|
||||||
|
queueIndex: store.currentIndex,
|
||||||
|
position: store.position,
|
||||||
|
isPlaying: store.isPlaying,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 409 means another device owns the session now. Its next state event is
|
||||||
|
// what corrects this one, so there is nothing to do here.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applySnapshot = useCallback(async (state: PlaybackSnapshot) => {
|
||||||
|
if (state.version <= lastVersion.current) return;
|
||||||
|
lastVersion.current = state.version;
|
||||||
|
setOwnerId(state.deviceId);
|
||||||
|
|
||||||
|
const store = usePlaybackStore.getState();
|
||||||
|
const iOwnIt = state.deviceId !== null && state.deviceId === deviceIdRef.current;
|
||||||
|
|
||||||
|
applyingRemote.current = true;
|
||||||
|
try {
|
||||||
|
if (!iOwnIt) {
|
||||||
|
// Another device holds the audio. Show what it plays, make no sound.
|
||||||
|
if (store.isPlaying) store.pause();
|
||||||
|
if (state.queue.length > 0) store.setQueue(state.queue);
|
||||||
|
const shown = state.queue[state.queueIndex] ?? store.currentTrack;
|
||||||
|
if (shown && shown.id !== store.currentTrack?.id) store.setCurrentTrack(shown);
|
||||||
|
store.setPosition(state.position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (track && track.id !== store.currentTrack?.id) store.playTrack(track);
|
||||||
|
store.setPosition(state.position);
|
||||||
|
if (state.isPlaying) store.play();
|
||||||
|
else store.pause();
|
||||||
|
} finally {
|
||||||
|
applyingRemote.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyCommand = useCallback(async (command: PlaybackCommand) => {
|
||||||
|
const store = usePlaybackStore.getState();
|
||||||
|
applyingRemote.current = true;
|
||||||
|
try {
|
||||||
|
switch (command.type) {
|
||||||
|
case 'play': store.play(); break;
|
||||||
|
case 'pause': store.pause(); break;
|
||||||
|
case 'next': store.next(); break;
|
||||||
|
case 'prev': store.prev(); break;
|
||||||
|
case 'seek': store.setPosition(command.position); break;
|
||||||
|
case 'play_track': {
|
||||||
|
const queued = store.queue.find((t) => t.id === command.trackId);
|
||||||
|
const track = queued ?? await trackService.getTrack(command.trackId).catch(() => null);
|
||||||
|
if (track) store.playTrack(track);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
applyingRemote.current = false;
|
||||||
|
}
|
||||||
|
// The command changed what this device plays, so the others need to know.
|
||||||
|
void reportNow();
|
||||||
|
}, [reportNow]);
|
||||||
|
|
||||||
|
// Register, then hold the stream open for as long as the app is mounted.
|
||||||
|
useEffect(() => {
|
||||||
|
let closeStream: (() => void) | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const device = await playbackSyncService.register().catch(() => null);
|
||||||
|
if (!device || cancelled) return;
|
||||||
|
setDeviceId(device.id);
|
||||||
|
deviceIdRef.current = device.id;
|
||||||
|
closeStream = playbackSyncService.openStream(device.id, (event) => {
|
||||||
|
if (event.type === 'state') {
|
||||||
|
setDevices(event.devices);
|
||||||
|
void applySnapshot(event.state);
|
||||||
|
} else {
|
||||||
|
void applyCommand(event.command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
closeStream?.();
|
||||||
|
};
|
||||||
|
}, [applySnapshot, applyCommand]);
|
||||||
|
|
||||||
|
// Local playback is reported upward — but only from the device that owns the
|
||||||
|
// audio, and only when the change did not come from the network in the first
|
||||||
|
// place. Starting playback on an unowned session claims it.
|
||||||
|
useEffect(() => {
|
||||||
|
let previous = usePlaybackStore.getState();
|
||||||
|
return usePlaybackStore.subscribe((state) => {
|
||||||
|
const prev = previous;
|
||||||
|
previous = state;
|
||||||
|
const id = deviceIdRef.current;
|
||||||
|
if (!id || applyingRemote.current) return;
|
||||||
|
|
||||||
|
const trackChanged = state.currentTrack?.id !== prev.currentTrack?.id;
|
||||||
|
const playingChanged = state.isPlaying !== prev.isPlaying;
|
||||||
|
const startedPlaying = state.isPlaying && !prev.isPlaying;
|
||||||
|
|
||||||
|
if (ownerIdRef.current !== id) {
|
||||||
|
// A device that does not own the session only takes it by starting
|
||||||
|
// playback here. Everything else it does stays local.
|
||||||
|
if (startedPlaying || (trackChanged && state.isPlaying)) {
|
||||||
|
void playbackSyncService.transfer(id).then(() => reportNow());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackChanged || playingChanged) {
|
||||||
|
void reportNow();
|
||||||
|
lastPositionReport.current = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastPositionReport.current >= POSITION_REPORT_MS) {
|
||||||
|
lastPositionReport.current = Date.now();
|
||||||
|
void reportNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [reportNow]);
|
||||||
|
|
||||||
|
// Leaving the page hands the session back rather than stranding it on a tab
|
||||||
|
// that is gone. The stream close does this too; this covers the browsers that
|
||||||
|
// keep a closing connection alive long enough to matter.
|
||||||
|
useEffect(() => {
|
||||||
|
const release = () => {
|
||||||
|
const id = deviceIdRef.current;
|
||||||
|
if (id && ownerIdRef.current === id) void playbackSyncService.release(id);
|
||||||
|
};
|
||||||
|
window.addEventListener('pagehide', release);
|
||||||
|
return () => window.removeEventListener('pagehide', release);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const transferTo = useCallback(async (target: string) => {
|
||||||
|
await playbackSyncService.transfer(target);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendCommand = useCallback(async (command: PlaybackCommand) => {
|
||||||
|
await playbackSyncService.sendCommand(command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
devices,
|
||||||
|
isOwner: ownerId !== null && ownerId === deviceId,
|
||||||
|
hasRemoteOwner: ownerId !== null && ownerId !== deviceId,
|
||||||
|
transferTo,
|
||||||
|
sendCommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { Track } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client half of cross-device playback. One browser is one device, identified
|
||||||
|
* by a stored id so a reload keeps its place in the device list instead of
|
||||||
|
* adding a new row every time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEVICE_ID_KEY = 'muzick.deviceId';
|
||||||
|
|
||||||
|
export type PlaybackCommand =
|
||||||
|
| { type: 'play' | 'pause' | 'next' | 'prev' }
|
||||||
|
| { type: 'seek'; position: number }
|
||||||
|
| { type: '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: Track[];
|
||||||
|
queueIndex: number;
|
||||||
|
position: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
version: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaybackSyncEvent =
|
||||||
|
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
|
||||||
|
| { type: 'command'; deviceId: string; command: PlaybackCommand };
|
||||||
|
|
||||||
|
export function storedDeviceId(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DEVICE_ID_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberDeviceId(id: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// Private browsing: the device still works, it just re-registers next load.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name the listener can tell apart in a device menu. The user agent is the
|
||||||
|
* only thing a browser will say about its host, so this reads platform and
|
||||||
|
* browser out of it rather than showing the raw string.
|
||||||
|
*/
|
||||||
|
export function describeThisDevice(): string {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const platform = /iPhone|iPad|iPod/.test(ua) ? 'iPhone'
|
||||||
|
: /Android/.test(ua) ? 'Android'
|
||||||
|
: /Macintosh/.test(ua) ? 'Mac'
|
||||||
|
: /Windows/.test(ua) ? 'Windows'
|
||||||
|
: /Linux/.test(ua) ? 'Linux'
|
||||||
|
: 'Device';
|
||||||
|
const browser = /Firefox\//.test(ua) ? 'Firefox'
|
||||||
|
: /Edg\//.test(ua) ? 'Edge'
|
||||||
|
: /Chrome\//.test(ua) ? 'Chrome'
|
||||||
|
: /Safari\//.test(ua) ? 'Safari'
|
||||||
|
: 'Browser';
|
||||||
|
return `${platform} · ${browser}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const playbackSyncService = {
|
||||||
|
async register(): Promise<PlaybackDevice> {
|
||||||
|
const res = await api.post<PlaybackDevice>('/playback/devices', {
|
||||||
|
deviceId: storedDeviceId(),
|
||||||
|
name: describeThisDevice(),
|
||||||
|
});
|
||||||
|
rememberDeviceId(res.data.id);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getState(): Promise<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }> {
|
||||||
|
const res = await api.get<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }>('/playback/state');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Report what this device is playing. Rejected with 409 once it is not the owner. */
|
||||||
|
async reportState(deviceId: string, patch: {
|
||||||
|
trackId?: string | null;
|
||||||
|
queue?: Track[];
|
||||||
|
queueIndex?: number;
|
||||||
|
position?: number;
|
||||||
|
isPlaying?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
await api.post('/playback/state', { deviceId, ...patch });
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendCommand(command: PlaybackCommand): Promise<void> {
|
||||||
|
await api.post('/playback/command', command);
|
||||||
|
},
|
||||||
|
|
||||||
|
async transfer(deviceId: string): Promise<void> {
|
||||||
|
await api.post('/playback/transfer', { deviceId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async release(deviceId: string): Promise<void> {
|
||||||
|
await api.post('/playback/release', { deviceId });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the push channel. EventSource reconnects on its own, and the server
|
||||||
|
* resends the full snapshot on connect, so a dropped stream self-heals
|
||||||
|
* without any resume bookkeeping here.
|
||||||
|
*/
|
||||||
|
openStream(deviceId: string, onEvent: (event: PlaybackSyncEvent) => void): () => void {
|
||||||
|
const base = (import.meta.env.VITE_API_URL as string | undefined) || '/api';
|
||||||
|
const source = new EventSource(`${base}/playback/stream?deviceId=${encodeURIComponent(deviceId)}`);
|
||||||
|
source.onmessage = (message) => {
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(message.data) as PlaybackSyncEvent);
|
||||||
|
} catch {
|
||||||
|
// A malformed frame is not worth tearing the stream down for.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => source.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user