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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user