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:
@@ -9,6 +9,7 @@ import { LyricsOverlay } from './LyricsOverlay';
|
||||
import { Toaster } from './Toaster';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
||||
|
||||
export default function AppShell() {
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
@@ -47,6 +48,7 @@ export default function AppShell() {
|
||||
});
|
||||
|
||||
return (
|
||||
<PlaybackSyncProvider>
|
||||
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
|
||||
<KeyboardListener />
|
||||
<TopBar
|
||||
@@ -72,5 +74,6 @@ export default function AppShell() {
|
||||
<Toaster />
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
</div>
|
||||
</PlaybackSyncProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,9 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* 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">
|
||||
{/* Search input */}
|
||||
<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 { ArtistLinks } from './ArtistLinks';
|
||||
import { formatDuration } from './TrackRow';
|
||||
import { DevicePicker } from './DevicePicker';
|
||||
import { usePlaybackSyncContext } from './PlaybackSyncProvider';
|
||||
|
||||
interface PlaybackBarProps {
|
||||
queueOpen: boolean;
|
||||
@@ -16,6 +18,18 @@ interface 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 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 = () => {
|
||||
if (!currentTrack) return;
|
||||
@@ -80,18 +94,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
>
|
||||
<Shuffle size={18} />
|
||||
</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} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
onClick={() => (isPlaying ? remote.pause() : remote.play())}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</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} />
|
||||
</button>
|
||||
<button
|
||||
@@ -108,7 +122,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
<input
|
||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||
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}
|
||||
className="flex-1 h-1 cursor-pointer"
|
||||
aria-label="Seek"
|
||||
@@ -126,6 +140,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
className="hidden w-20 h-1 cursor-pointer sm:block"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<DevicePicker />
|
||||
<button
|
||||
onClick={onToggleLyrics}
|
||||
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