4ead344aec
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>
84 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|