From 85ca9cf54385319b5fab899a5b86497b0517b60e Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 8 Aug 2026 20:56:30 +0400 Subject: [PATCH] fix(ui): one transport for every control, and a queue panel that fits The queue panel drove the playback store directly, so its buttons played locally while another device held the audio. Both control sets now go through one transport that forwards a press when the audio is elsewhere. The panel's artwork is capped against viewport height too: at full width on a phone the square alone pushed Up Next off the screen. Co-Authored-By: Claude Opus 5 --- frontend/src/components/NowPlayingPanel.tsx | 21 ++++-- frontend/src/components/PlaybackBar.tsx | 25 ++----- .../src/components/PlaybackSyncProvider.tsx | 11 ++- frontend/src/hooks/useTransport.test.tsx | 68 +++++++++++++++++++ frontend/src/hooks/useTransport.ts | 48 +++++++++++++ 5 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 frontend/src/hooks/useTransport.test.tsx create mode 100644 frontend/src/hooks/useTransport.ts diff --git a/frontend/src/components/NowPlayingPanel.tsx b/frontend/src/components/NowPlayingPanel.tsx index 2b29d60..9702f0a 100644 --- a/frontend/src/components/NowPlayingPanel.tsx +++ b/frontend/src/components/NowPlayingPanel.tsx @@ -7,6 +7,7 @@ import { Artwork } from './Artwork'; import { ArtistLinks } from './ArtistLinks'; import { TrackRow, formatDuration } from './TrackRow'; import { albumService } from '../services/albumService'; +import { useTransport } from '../hooks/useTransport'; export function NowPlayingPanel({ onClose }: { onClose: () => void }) { const panelRef = useRef(null); @@ -34,7 +35,8 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) { previous?.focus(); }; }, []); - const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore(); + const { currentTrack, queue, isPlaying, position, duration } = usePlaybackStore(); + const transport = useTransport(); const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue; @@ -56,17 +58,21 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) { + {/* The artwork below is capped against viewport height, not just width. + At full width on a phone the square alone is taller than the space + between the top bar and the transport, which pushed Up Next — the + reason the panel opens — entirely off the screen. */}
{currentTrack?.album_id ? ( + className="group mx-auto block aspect-square w-full max-w-[min(100%,34vh)] rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
) : ( -
+
)} @@ -90,7 +96,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) { setPosition(Number(e.target.value))} + onChange={(e) => transport.seek(Number(e.target.value))} disabled={!currentTrack || duration <= 0} className="w-full cursor-pointer" /> @@ -101,15 +107,16 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
- + - +
diff --git a/frontend/src/components/PlaybackBar.tsx b/frontend/src/components/PlaybackBar.tsx index 5b63289..4afadfc 100644 --- a/frontend/src/components/PlaybackBar.tsx +++ b/frontend/src/components/PlaybackBar.tsx @@ -6,7 +6,7 @@ import { Artwork } from './Artwork'; import { ArtistLinks } from './ArtistLinks'; import { formatDuration } from './TrackRow'; import { DevicePicker } from './DevicePicker'; -import { usePlaybackSyncContext } from './PlaybackSyncProvider'; +import { useTransport } from '../hooks/useTransport'; interface PlaybackBarProps { queueOpen: boolean; @@ -16,20 +16,9 @@ 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 { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, 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 transport = useTransport(); const handleDislike = () => { if (!currentTrack) return; @@ -94,18 +83,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri > - -