98 lines
3.7 KiB
TypeScript
98 lines
3.7 KiB
TypeScript
import { useNavigate } from 'react-router-dom'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Play, X } from '../../lib/icons'
|
|
import type { BaseItemDto } from '../../api/types'
|
|
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
|
import { formatRuntime } from '../../lib/format'
|
|
|
|
interface Props {
|
|
nextItem?: BaseItemDto | null
|
|
visible: boolean
|
|
countdownSeconds: number
|
|
onSkip: () => void
|
|
onDismiss: () => void
|
|
}
|
|
|
|
export default function UpNext({
|
|
nextItem,
|
|
visible,
|
|
countdownSeconds,
|
|
onSkip,
|
|
onDismiss,
|
|
}: Props) {
|
|
const navigate = useNavigate()
|
|
const serverUrl = getStoredServerUrl()
|
|
|
|
if (!nextItem) return null
|
|
|
|
const thumb = getBestImage(serverUrl, nextItem, 'thumb', 480)
|
|
const epLabel =
|
|
nextItem.ParentIndexNumber != null && nextItem.IndexNumber != null
|
|
? `S${nextItem.ParentIndexNumber} · E${nextItem.IndexNumber}`
|
|
: ''
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{visible && (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 30, y: 0 }}
|
|
animate={{ opacity: 1, x: 0, y: 0 }}
|
|
exit={{ opacity: 0, x: 30 }}
|
|
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute bottom-32 right-6 w-[360px] glass-strong rounded-xl border border-white/15 overflow-hidden shadow-2xl pointer-events-auto"
|
|
>
|
|
<button
|
|
onClick={onDismiss}
|
|
className="absolute top-2 right-2 z-10 w-7 h-7 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur grid place-items-center text-white/70 hover:text-white transition-colors focus-ring"
|
|
aria-label="Dismiss up next"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
|
|
<div className="relative aspect-video bg-elevated">
|
|
{thumb && <img src={thumb} alt="" className="w-full h-full object-cover" />}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
|
<div className="absolute bottom-0 left-0 right-0 p-3">
|
|
<p className="text-[10px] uppercase tracking-[0.14em] text-accent font-semibold mb-0.5">
|
|
Up next in {countdownSeconds}s
|
|
</p>
|
|
{epLabel && (
|
|
<p className="text-[11px] text-white/65 font-mono mb-0.5">{epLabel}</p>
|
|
)}
|
|
<p className="text-[14px] text-white font-semibold tracking-tight line-clamp-1">
|
|
{nextItem.Name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 space-y-2">
|
|
{nextItem.Overview && (
|
|
<p className="text-[11.5px] text-white/65 line-clamp-2 leading-relaxed">{nextItem.Overview}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<button
|
|
onClick={onSkip}
|
|
className="flex-1 h-9 px-3 bg-white text-void rounded-md flex items-center justify-center gap-1.5 text-[12.5px] font-semibold transition-all duration-150 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
|
>
|
|
<Play size={13} fill="currentColor" />
|
|
Play now
|
|
</button>
|
|
<button
|
|
onClick={() => nextItem.Id && navigate(`/item/${nextItem.Id}`)}
|
|
className="h-9 px-3 bg-white/10 hover:bg-white/15 text-white border border-white/15 rounded-md text-[12.5px] font-medium transition-colors focus-ring"
|
|
>
|
|
Details
|
|
</button>
|
|
{nextItem.RunTimeTicks && (
|
|
<span className="text-[11px] text-white/55 tabular-nums">
|
|
{formatRuntime(nextItem.RunTimeTicks)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|