4ead344aec
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>
209 lines
8.2 KiB
TypeScript
209 lines
8.2 KiB
TypeScript
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(() => {});
|
|
});
|
|
});
|
|
}
|