Sidebar: - Discord-style active indicator pill (left edge, primary color) - Hover micro-animations: icon scale-110, bg transition - Section dividers between nav groups - Notification badge pill (primary/15 bg, font-semibold) - Footer items (Settings, Sign Out) consistent with main nav Player bar: - Progress bar: time preview tooltip on hover, scale-y on drag - Volume: Spotify-style hover-reveal slider, 3-level icon states - Now playing: ambient glow behind album art on hover - Track info: clickable artist name with hover underline - Keyboard shortcuts: N/P (next/prev), Arrow Up/Down (volume), M (mute) - Shortcut hints in control tooltips (<kbd> badges) Co-authored-by: Cursor <cursoragent@cursor.com>
125 lines
3.8 KiB
TypeScript
125 lines
3.8 KiB
TypeScript
import { useRef, useState, useCallback } from 'react';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { cn } from '@/lib/utils';
|
|
import { formatTime } from './types';
|
|
|
|
interface AudioPlayerProgressProps {
|
|
currentTime: number;
|
|
duration: number;
|
|
onSeek: (value: number[]) => void;
|
|
/** "default" shows time labels on sides; "minimal" renders a thin full-width bar (for top-of-player placement). */
|
|
variant?: 'default' | 'minimal';
|
|
}
|
|
|
|
export function AudioPlayerProgress({
|
|
currentTime,
|
|
duration,
|
|
onSeek,
|
|
variant = 'default',
|
|
}: AudioPlayerProgressProps) {
|
|
const [hoverTime, setHoverTime] = useState<number | null>(null);
|
|
const [tooltipX, setTooltipX] = useState(0);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const barRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!barRef.current || !duration) return;
|
|
const rect = barRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const pct = Math.max(0, Math.min(1, x / rect.width));
|
|
setHoverTime(pct * duration);
|
|
setTooltipX(pct * 100);
|
|
},
|
|
[duration],
|
|
);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
if (!isDragging) setHoverTime(null);
|
|
}, [isDragging]);
|
|
|
|
const handlePointerDown = useCallback(() => setIsDragging(true), []);
|
|
const handlePointerUp = useCallback(() => {
|
|
setIsDragging(false);
|
|
setHoverTime(null);
|
|
}, []);
|
|
|
|
if (variant === 'minimal') {
|
|
return (
|
|
<div
|
|
ref={barRef}
|
|
className={cn(
|
|
'w-full group relative',
|
|
isDragging && 'cursor-grabbing',
|
|
)}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
>
|
|
<Slider
|
|
value={[currentTime]}
|
|
max={duration || 1}
|
|
step={0.1}
|
|
onValueChange={onSeek}
|
|
className={cn('w-full transition-all', isDragging && 'scale-y-150')}
|
|
aria-label="Track progress"
|
|
/>
|
|
{/* Time preview tooltip */}
|
|
{hoverTime !== null && duration > 0 && (
|
|
<div
|
|
className="absolute bottom-full mb-2 px-2 py-1 bg-card text-foreground text-xs rounded shadow-lg pointer-events-none whitespace-nowrap z-50"
|
|
style={{
|
|
left: `${tooltipX}%`,
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
>
|
|
{formatTime(hoverTime)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<span className="text-xs text-muted-foreground w-12 text-right">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<div
|
|
ref={barRef}
|
|
className={cn(
|
|
'flex-1 group relative',
|
|
isDragging && 'cursor-grabbing',
|
|
)}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
>
|
|
<Slider
|
|
value={[currentTime]}
|
|
max={duration || 1}
|
|
step={0.1}
|
|
onValueChange={onSeek}
|
|
className={cn('w-full transition-all', isDragging && 'scale-y-150')}
|
|
aria-label="Track progress"
|
|
/>
|
|
{hoverTime !== null && duration > 0 && (
|
|
<div
|
|
className="absolute bottom-full mb-2 px-2 py-1 bg-card text-foreground text-xs rounded shadow-lg pointer-events-none whitespace-nowrap z-50"
|
|
style={{
|
|
left: `${tooltipX}%`,
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
>
|
|
{formatTime(hoverTime)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground w-12">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|