Files
muzick/frontend/src/components/DevicePicker.tsx
T
kami 4ead344aec 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>
2026-08-08 19:12:26 +04:00

84 lines
3.3 KiB
TypeScript

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>
);
}