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:
kami
2026-08-08 19:12:26 +04:00
parent bfe22745bc
commit 4ead344aec
13 changed files with 1194 additions and 5 deletions
+3
View File
@@ -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>
);
}
+3 -1
View File
@@ -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">
+83
View File
@@ -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>
);
}
+19 -4
View File
@@ -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;
}