85ca9cf543
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 <noreply@anthropic.com>
155 lines
7.2 KiB
TypeScript
155 lines
7.2 KiB
TypeScript
import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic, Shuffle, Repeat, Repeat1, MicVocal, ThumbsDown } from 'lucide-react';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { usePlaybackStore } from '../store/usePlaybackStore';
|
|
import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
|
import { Artwork } from './Artwork';
|
|
import { ArtistLinks } from './ArtistLinks';
|
|
import { formatDuration } from './TrackRow';
|
|
import { DevicePicker } from './DevicePicker';
|
|
import { useTransport } from '../hooks/useTransport';
|
|
|
|
interface PlaybackBarProps {
|
|
queueOpen: boolean;
|
|
lyricsOpen: boolean;
|
|
onToggleQueue: () => void;
|
|
onToggleLyrics: () => void;
|
|
}
|
|
|
|
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
|
|
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
|
const dislikeTrack = useDislikeTrack();
|
|
const transport = useTransport();
|
|
|
|
const handleDislike = () => {
|
|
if (!currentTrack) return;
|
|
dislikeTrack(currentTrack.id);
|
|
};
|
|
|
|
return (
|
|
<div className="glass border-t border-border/70 px-3 py-2 shrink-0 z-20 sm:h-20 sm:px-4 sm:py-0">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
|
|
{/* Track info */}
|
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
|
{currentTrack ? (
|
|
<>
|
|
{currentTrack.album_id ? (
|
|
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
|
className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40 ring-1 ring-border/50 hover:ring-accent/50 transition-shadow" title="Go to album">
|
|
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={currentTrack.artwork_id} className="w-full h-full" rounded="lg" eager />
|
|
</Link>
|
|
) : (
|
|
<div className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40 ring-1 ring-border/50">
|
|
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={currentTrack.artwork_id} className="w-full h-full" rounded="lg" eager />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
{currentTrack.album_id ? (
|
|
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
|
className="block text-sm font-semibold text-text truncate hover:underline">
|
|
{currentTrack.title}
|
|
</Link>
|
|
) : (
|
|
<div className="text-sm font-semibold text-text truncate">{currentTrack.title}</div>
|
|
)}
|
|
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-xs text-muted truncate" />
|
|
</div>
|
|
<button
|
|
onClick={handleDislike}
|
|
title="Dislike (sends to quarantine)"
|
|
className="flex-none rounded-md p-2 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
|
|
aria-label="Dislike — move to quarantine"
|
|
>
|
|
<ThumbsDown size={16} />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="h-12 w-12 flex-none rounded-lg border border-border bg-bg2" aria-hidden />
|
|
{/* Reached only before anything has ever played in this browser —
|
|
a returning tab restores its last track instead. */}
|
|
<div className="text-sm text-muted">Pick a track to start</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls + scrubber */}
|
|
<div className="order-3 flex basis-full flex-col items-center gap-1 sm:order-none sm:flex-1 sm:basis-auto">
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
<button
|
|
onClick={toggleShuffle}
|
|
className={`rounded-md p-2 transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
|
|
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
|
|
title={shuffle ? 'Shuffle on' : 'Shuffle off'}
|
|
>
|
|
<Shuffle size={18} />
|
|
</button>
|
|
<button onClick={transport.prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
|
|
<SkipBack size={20} />
|
|
</button>
|
|
<button
|
|
onClick={transport.toggle}
|
|
disabled={!currentTrack}
|
|
className="transport-btn"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
|
</button>
|
|
<button onClick={transport.next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
|
|
<SkipForward size={20} />
|
|
</button>
|
|
<button
|
|
onClick={cycleRepeat}
|
|
className={`rounded-md p-2 transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
|
|
aria-label={`Repeat: ${repeat}`}
|
|
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
|
|
>
|
|
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
|
</button>
|
|
</div>
|
|
<div className="flex w-full max-w-xl items-center gap-2">
|
|
<span className="w-10 flex-none text-right font-mono text-xs tabular-nums text-machine">{formatDuration(position)}</span>
|
|
<input
|
|
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
|
value={Math.min(position, duration || 0)}
|
|
onChange={(e) => transport.seek(Number(e.target.value))}
|
|
disabled={!currentTrack || duration <= 0}
|
|
className="flex-1 h-1 cursor-pointer"
|
|
aria-label="Seek"
|
|
/>
|
|
<span className="w-10 flex-none font-mono text-xs tabular-nums text-machine">{formatDuration(duration)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Volume + panel toggle */}
|
|
<div className="order-2 flex items-center gap-1 justify-end shrink-0 sm:order-none sm:w-48 sm:gap-3">
|
|
<Volume2 size={18} className="hidden text-muted flex-none sm:block" />
|
|
<input
|
|
type="range" min={0} max={1} step={0.01} value={volume}
|
|
onChange={(e) => setVolume(Number(e.target.value))}
|
|
className="hidden w-20 h-1 cursor-pointer sm:block"
|
|
aria-label="Volume"
|
|
/>
|
|
<DevicePicker />
|
|
<button
|
|
onClick={onToggleLyrics}
|
|
disabled={!currentTrack}
|
|
className={`rounded-md p-2 transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
|
|
aria-label="Toggle lyrics"
|
|
title="Lyrics"
|
|
>
|
|
<MicVocal size={18} />
|
|
</button>
|
|
<button
|
|
onClick={onToggleQueue}
|
|
className={`rounded-md p-2 transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
|
|
aria-label="Toggle queue panel"
|
|
title="Up Next"
|
|
>
|
|
<ListMusic size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|