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:
@@ -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