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
+132
View File
@@ -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();
},
};