feat(playback): move a session between devices

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-08 19:12:26 +04:00
parent bfe22745bc
commit 4ead344aec
13 changed files with 1194 additions and 5 deletions
+32
View File
@@ -819,4 +819,36 @@ export const MIGRATIONS: Migration[] = [
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
`,
},
{
// One listener, many browsers. `playback_state` is the single authority for
// what is playing and which device owns the audio, so a phone can take over
// from a desktop mid-track. The queue is stored as whole track objects, not
// ids: the device taking over needs to render the queue immediately, and a
// snapshot of what was queued at handoff time is the honest thing to move.
id: '20260808_playback_devices',
sql: `
CREATE TABLE IF NOT EXISTS playback_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name TEXT NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_playback_devices_user
ON playback_devices (user_id, last_seen_at DESC);
CREATE TABLE IF NOT EXISTS playback_state (
user_id UUID PRIMARY KEY,
device_id UUID REFERENCES playback_devices(id) ON DELETE SET NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
queue JSONB NOT NULL DEFAULT '[]'::jsonb,
queue_index INTEGER NOT NULL DEFAULT -1,
position_ms INTEGER NOT NULL DEFAULT 0,
is_playing BOOLEAN NOT NULL DEFAULT FALSE,
-- Monotonic per user. A device applies a snapshot only when it is newer
-- than the last one it saw, so a delayed delivery cannot rewind anyone.
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`,
},
];