Files
jellybloom/src/components/player/NowPlaying.tsx
T

392 lines
17 KiB
TypeScript

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>
)
}