player components
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Shuffle,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
X,
|
||||
ListMusic,
|
||||
Heart,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
ChevronDown,
|
||||
} from '../../lib/icons'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useMusicStore } from '../../stores/music-store'
|
||||
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
||||
|
||||
interface NowPlayingProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function NowPlaying({ isOpen, onClose }: NowPlayingProps) {
|
||||
const [showQueue, setShowQueue] = useState(false)
|
||||
const {
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
shuffle,
|
||||
repeat,
|
||||
queue,
|
||||
queueIndex,
|
||||
volume,
|
||||
isMuted,
|
||||
pause,
|
||||
resume,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
toggleShuffle,
|
||||
cycleRepeat,
|
||||
seekTo,
|
||||
toggleMute,
|
||||
setVolume,
|
||||
} = useMusicStore()
|
||||
|
||||
const serverUrl = getStoredServerUrl()
|
||||
|
||||
const imageUrl = currentTrack
|
||||
? getBestImage(serverUrl, currentTrack, 'primary', 800)
|
||||
: ''
|
||||
const bgImageUrl = currentTrack
|
||||
? getBestImage(serverUrl, currentTrack, 'primary', 320)
|
||||
: ''
|
||||
|
||||
const title = currentTrack?.Name || 'Unknown track'
|
||||
const artist = currentTrack?.AlbumArtist || currentTrack?.Artists?.[0] || ''
|
||||
const album = currentTrack?.Album || ''
|
||||
|
||||
const RepeatIcon = repeat === 'one' ? Repeat1 : Repeat
|
||||
const VolIcon = isMuted || volume === 0 ? VolumeX : Volume2
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!s || !isFinite(s)) return '0:00'
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
seekTo(pct * duration)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && currentTrack && (
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="fixed inset-0 z-fullscreen flex"
|
||||
>
|
||||
{/* ── Cinematic background ────────────────────────────── */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{bgImageUrl && (
|
||||
<>
|
||||
{/* Heavy blurred album art as ambient backdrop */}
|
||||
<motion.div
|
||||
initial={{ scale: 1.04 }}
|
||||
animate={{ scale: 1.12 }}
|
||||
transition={{ duration: 30, ease: 'linear', repeat: Infinity, repeatType: 'reverse' }}
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${bgImageUrl})`,
|
||||
filter: 'blur(80px) saturate(140%)',
|
||||
opacity: 0.55,
|
||||
}}
|
||||
/>
|
||||
{/* Color wash */}
|
||||
<div className="absolute inset-0 bg-void/65" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-void/40 via-transparent to-void/80" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Header ────────────────────────────────────────── */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-6 pt-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="group flex items-center gap-2 text-white/70 hover:text-white transition-colors px-2 py-1 rounded-md focus-ring"
|
||||
>
|
||||
<ChevronDown size={20} strokeWidth={2.25} className="transition-transform duration-200 group-hover:translate-y-0.5" />
|
||||
<span className="text-[12px] font-medium uppercase tracking-[0.14em]">Hide</span>
|
||||
</button>
|
||||
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-white/55 font-semibold">
|
||||
Now playing
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-full bg-white/8 hover:bg-white/15 backdrop-blur grid place-items-center text-white/70 hover:text-white transition-all duration-200 focus-ring"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ─────────────────────────────────── */}
|
||||
<div className="relative flex-1 flex flex-col items-center justify-center px-8 pt-14 pb-10">
|
||||
{/* Album art */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
|
||||
className="relative mb-9"
|
||||
>
|
||||
{/* Glow halo */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-3xl scale-110" />
|
||||
{/* Reflection-like shadow */}
|
||||
<div className="absolute -bottom-6 left-4 right-4 h-12 bg-black/60 blur-2xl rounded-full opacity-70" />
|
||||
|
||||
<div className="relative w-[300px] h-[300px] md:w-[340px] md:h-[340px] rounded-2xl overflow-hidden ring-1 ring-white/10 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.8)] bg-elevated">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
|
||||
<ListMusic size={72} className="text-text-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Track info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.3 }}
|
||||
className="text-center mb-7 max-w-2xl"
|
||||
>
|
||||
<h2 className="font-display text-3xl md:text-4xl font-bold text-white tracking-tight mb-2 line-clamp-2">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-[15px] text-white/70 truncate">
|
||||
{artist}
|
||||
{album && (
|
||||
<>
|
||||
<span className="text-white/30 mx-2">·</span>
|
||||
<span className="text-white/55">{album}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Scrubber */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
|
||||
className="w-full max-w-md mb-6"
|
||||
>
|
||||
<div
|
||||
onClick={handleScrubClick}
|
||||
className="relative h-5 cursor-pointer group/scrub"
|
||||
>
|
||||
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 group-hover/scrub:h-1.5 rounded-full bg-white/15 overflow-hidden transition-all duration-150">
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
|
||||
style={{ width: `${playedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-accent shadow-[0_0_12px_rgba(245,182,66,0.6)] pointer-events-none transition-opacity duration-150 opacity-0 group-hover/scrub:opacity-100"
|
||||
style={{ left: `${playedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-white/55 mt-1.5 tabular-nums font-medium">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Transport */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.5 }}
|
||||
className="flex items-center gap-5 mb-6"
|
||||
>
|
||||
<NPButton onClick={toggleShuffle} active={shuffle} aria-label="Shuffle">
|
||||
<Shuffle size={18} />
|
||||
</NPButton>
|
||||
<NPButton onClick={prevTrack} aria-label="Previous">
|
||||
<SkipBack size={22} fill="currentColor" />
|
||||
</NPButton>
|
||||
|
||||
<button
|
||||
onClick={isPlaying ? pause : resume}
|
||||
className="relative w-16 h-16 rounded-full bg-white text-void grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 shadow-[0_8px_24px_-6px_rgba(0,0,0,0.6)] focus-ring"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-white/30 blur-2xl scale-110 opacity-0 hover:opacity-100 transition-opacity" />
|
||||
{isPlaying ? (
|
||||
<Pause size={26} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={26} fill="currentColor" className="translate-x-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<NPButton onClick={nextTrack} aria-label="Next">
|
||||
<SkipForward size={22} fill="currentColor" />
|
||||
</NPButton>
|
||||
<NPButton onClick={cycleRepeat} active={repeat !== 'off'} aria-label="Repeat">
|
||||
<RepeatIcon size={18} />
|
||||
</NPButton>
|
||||
</motion.div>
|
||||
|
||||
{/* Secondary controls */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<NPButton aria-label="Favorite">
|
||||
<Heart
|
||||
size={16}
|
||||
className={currentTrack.UserData?.IsFavorite ? 'text-accent' : ''}
|
||||
fill={currentTrack.UserData?.IsFavorite ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</NPButton>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2 px-3 h-9 rounded-full bg-white/8 hover:bg-white/12 transition-colors">
|
||||
<button onClick={toggleMute} className="text-white/70 hover:text-white transition-colors">
|
||||
<VolIcon size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={e => setVolume(Number(e.target.value))}
|
||||
className="slider w-24"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQueue(!showQueue)}
|
||||
className={`flex items-center gap-2 h-9 px-3 rounded-full transition-all duration-200 text-[12px] font-medium focus-ring ${
|
||||
showQueue
|
||||
? 'bg-accent/15 text-accent border border-accent/25'
|
||||
: 'bg-white/8 text-white/70 hover:bg-white/12 hover:text-white border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<ListMusic size={14} />
|
||||
Queue
|
||||
<span className="text-white/40">·</span>
|
||||
<span className="tabular-nums">{queue.length}</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* ── Queue panel ───────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{showQueue && (
|
||||
<motion.div
|
||||
initial={{ x: 360, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 360, opacity: 0 }}
|
||||
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-[340px] glass-strong border-l border-border overflow-y-auto content-scroll"
|
||||
>
|
||||
<div className="sticky top-0 px-6 py-4 bg-glass-strong backdrop-blur-xl border-b border-border z-10">
|
||||
<p className="text-[10px] font-semibold text-text-2 uppercase tracking-[0.14em]">Up next</p>
|
||||
<h3 className="text-[15px] font-semibold text-text-1 tracking-tight mt-0.5">
|
||||
{queue.length} {queue.length === 1 ? 'track' : 'tracks'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-3 space-y-1">
|
||||
{queue.map((track, i) => {
|
||||
const isActive = i === queueIndex
|
||||
const thumbUrl = getBestImage(serverUrl, track, 'primary', 100)
|
||||
return (
|
||||
<div
|
||||
key={`${track.Id}-${i}`}
|
||||
className={`group flex items-center gap-3 p-2 rounded-lg transition-all duration-150 ${
|
||||
isActive ? 'bg-accent/12 ring-1 ring-accent/25' : 'hover:bg-glass-light'
|
||||
}`}
|
||||
>
|
||||
<div className="relative w-10 h-10 rounded-md overflow-hidden bg-elevated shrink-0 ring-1 ring-border">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center text-text-3">
|
||||
<ListMusic size={14} />
|
||||
</div>
|
||||
)}
|
||||
{isActive && isPlaying && <Equalizer />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-[13px] truncate font-medium tracking-tight ${isActive ? 'text-accent' : 'text-text-1'}`}>
|
||||
{track.Name || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-[11.5px] text-text-3 truncate">
|
||||
{track.AlbumArtist || track.Artists?.[0] || 'Unknown artist'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-text-4 tabular-nums shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{track.RunTimeTicks
|
||||
? formatTime(Number(track.RunTimeTicks) / 10000000)
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function NPButton({
|
||||
active,
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`w-10 h-10 grid place-items-center rounded-full transition-all duration-200 hover:bg-white/12 focus-ring ${
|
||||
active ? 'text-accent bg-accent/12' : 'text-white/80 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Equalizer() {
|
||||
return (
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/55 backdrop-blur-sm">
|
||||
<div className="flex items-end gap-[2px] h-3.5">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] bg-accent rounded-full origin-bottom"
|
||||
style={{
|
||||
animation: `equalizer-bar 0.9s ease-in-out infinite`,
|
||||
animationDelay: `${i * 0.15}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user