341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
import { useEffect, useRef, useState, startTransition } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Play, Info, Flame, ChevronLeft, ChevronRight, Star, Clock, Tv2, Film } from '../../lib/icons'
|
|
import type { useNavigate } from 'react-router-dom'
|
|
import { formatRuntime } from '../../lib/format'
|
|
import { getBestImage, getImageUrl, getStoredServerUrl } from '../../api/jellyfin'
|
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
|
import type { BaseItemDto } from '../../api/types'
|
|
import { Dot } from './home-utils'
|
|
|
|
/* ───────────────────────────────────────────────────────────────── */
|
|
/* Featured Carousel - Ken Burns rotating hero */
|
|
/* ───────────────────────────────────────────────────────────────── */
|
|
|
|
export function FeaturedCarousel({
|
|
items,
|
|
navigate,
|
|
}: {
|
|
items: BaseItemDto[]
|
|
navigate: ReturnType<typeof useNavigate>
|
|
}) {
|
|
const [index, setIndex] = useState(0)
|
|
const [paused, setPaused] = useState(false)
|
|
const [progress, setProgress] = useState(0)
|
|
// Track whether the user navigated forward (next) or backward (prev) so
|
|
// the slide cross-fade has a slight directional drift.
|
|
const directionRef = useRef<1 | -1>(1)
|
|
|
|
const heroAutoAdvance = usePreferencesStore(s => s.heroAutoAdvance)
|
|
const heroAutoAdvanceMs = usePreferencesStore(s => s.heroAutoAdvanceMs)
|
|
const reduceMotion = usePreferencesStore(s => s.reduceMotion)
|
|
const advanceDisabled = !heroAutoAdvance || reduceMotion
|
|
|
|
const total = items.length
|
|
const item = items[index]
|
|
|
|
/* Auto-advance + smooth progress */
|
|
useEffect(() => {
|
|
if (paused || advanceDisabled || total <= 1) {
|
|
setProgress(0)
|
|
return
|
|
}
|
|
setProgress(0)
|
|
const start = Date.now()
|
|
const id = setInterval(() => {
|
|
const elapsed = Date.now() - start
|
|
const p = Math.min(1, elapsed / heroAutoAdvanceMs)
|
|
setProgress(p)
|
|
if (p >= 1) {
|
|
directionRef.current = 1
|
|
setIndex(i => (i + 1) % total)
|
|
}
|
|
}, 50)
|
|
return () => clearInterval(id)
|
|
}, [index, paused, total, advanceDisabled, heroAutoAdvanceMs])
|
|
|
|
/* Keyboard nav */
|
|
useEffect(() => {
|
|
if (total <= 1) return
|
|
function handle(e: KeyboardEvent) {
|
|
const tag = (document.activeElement?.tagName || '').toLowerCase()
|
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
|
|
if (e.key === 'ArrowLeft') {
|
|
directionRef.current = -1
|
|
setIndex(i => (i - 1 + total) % total)
|
|
} else if (e.key === 'ArrowRight') {
|
|
directionRef.current = 1
|
|
setIndex(i => (i + 1) % total)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handle)
|
|
return () => window.removeEventListener('keydown', handle)
|
|
}, [total])
|
|
|
|
function goPrev() {
|
|
directionRef.current = -1
|
|
startTransition(() => setIndex(i => (i - 1 + total) % total))
|
|
}
|
|
function goNext() {
|
|
directionRef.current = 1
|
|
startTransition(() => setIndex(i => (i + 1) % total))
|
|
}
|
|
function goTo(i: number) {
|
|
directionRef.current = i > index ? 1 : -1
|
|
startTransition(() => setIndex(i))
|
|
}
|
|
|
|
if (!item) return null
|
|
|
|
return (
|
|
<div
|
|
className="relative h-[62vh] min-h-[460px] max-h-[680px] -mt-14 mb-2 overflow-hidden group/hero"
|
|
onMouseEnter={() => setPaused(true)}
|
|
onMouseLeave={() => setPaused(false)}
|
|
role="region"
|
|
aria-label="Featured titles"
|
|
aria-roledescription="carousel"
|
|
>
|
|
<AnimatePresence mode="sync" initial={false}>
|
|
<FeaturedSlide
|
|
key={item.Id}
|
|
item={item}
|
|
navigate={navigate}
|
|
direction={directionRef.current}
|
|
/>
|
|
</AnimatePresence>
|
|
|
|
{/* Edge gradients to keep arrows + title legible regardless of backdrop */}
|
|
<div className="absolute inset-y-0 left-0 w-32 bg-gradient-to-r from-void/40 to-transparent pointer-events-none opacity-0 group-hover/hero:opacity-100 transition-opacity duration-300" />
|
|
<div className="absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-void/40 to-transparent pointer-events-none opacity-0 group-hover/hero:opacity-100 transition-opacity duration-300" />
|
|
|
|
{/* Bottom gradient blend into rows */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-b from-transparent to-void pointer-events-none" />
|
|
|
|
{/* Manual nav arrows - only when more than 1 slide */}
|
|
{total > 1 && (
|
|
<>
|
|
<CarouselArrow side="left" onClick={goPrev} />
|
|
<CarouselArrow side="right" onClick={goNext} />
|
|
</>
|
|
)}
|
|
|
|
{/* Indicators - Netflix-style filling progress bars */}
|
|
{total > 1 && (
|
|
<div className="absolute bottom-6 left-7 right-7 z-10 flex items-center gap-3 pointer-events-none">
|
|
<div className="flex items-center gap-1.5">
|
|
{items.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => goTo(i)}
|
|
aria-label={`Go to slide ${i + 1}`}
|
|
className="group/dot relative h-1 w-9 rounded-full bg-white/15 overflow-hidden pointer-events-auto cursor-pointer hover:h-1.5 transition-all duration-150"
|
|
>
|
|
<span
|
|
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
|
style={{
|
|
width: i < index ? '100%' : i === index ? `${progress * 100}%` : '0%',
|
|
transition: i === index ? 'none' : 'width 200ms ease-out',
|
|
}}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="ml-auto text-[10px] uppercase tracking-[0.18em] font-semibold text-white/45 tabular-nums">
|
|
{String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CarouselArrow({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) {
|
|
const Icon = side === 'left' ? ChevronLeft : ChevronRight
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
aria-label={side === 'left' ? 'Previous slide' : 'Next slide'}
|
|
className={`absolute top-1/2 -translate-y-1/2 z-10 w-12 h-12 grid place-items-center rounded-full glass-strong border border-white/15 text-white/85 hover:text-white hover:scale-105 active:scale-95 transition-all duration-200 shadow-lg shadow-black/40 opacity-0 group-hover/hero:opacity-100 focus-ring ${
|
|
side === 'left' ? 'left-5' : 'right-5'
|
|
}`}
|
|
>
|
|
<Icon size={18} stroke={2.25} />
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function FeaturedSlide({
|
|
item,
|
|
navigate,
|
|
direction,
|
|
}: {
|
|
item: BaseItemDto
|
|
navigate: ReturnType<typeof useNavigate>
|
|
direction: 1 | -1
|
|
}) {
|
|
const serverUrl = getStoredServerUrl()
|
|
|
|
const reduceMotion = usePreferencesStore(s => s.reduceMotion)
|
|
|
|
const backdrop = getBestImage(serverUrl, item, 'backdrop', 1920)
|
|
const logo = item.Id && item.ImageTags?.Logo
|
|
? getImageUrl(serverUrl, item.Id, 'Logo', 600, item.ImageTags.Logo)
|
|
: null
|
|
|
|
const title = item.Name || ''
|
|
const overview = item.Overview || ''
|
|
const year = item.ProductionYear
|
|
const rating = item.OfficialRating
|
|
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
|
|
const genres = (item.Genres || []).slice(0, 3)
|
|
const community = item.CommunityRating
|
|
const progress = item.UserData?.PlayedPercentage
|
|
const hasProgress = typeof progress === 'number' && progress > 0
|
|
const isSeries = item.Type === 'Series'
|
|
const TypeIcon = isSeries ? Tv2 : Film
|
|
const tagline = (item as { Taglines?: string[] }).Taglines?.[0]
|
|
|
|
return (
|
|
<motion.div
|
|
key={item.Id}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute inset-0"
|
|
>
|
|
{/* Ken Burns backdrop - slow scale + horizontal pan, disabled when reduce-motion is on */}
|
|
{backdrop && (
|
|
<motion.div
|
|
initial={reduceMotion ? { scale: 1, x: 0 } : { scale: 1.04, x: direction === 1 ? -12 : 12 }}
|
|
animate={reduceMotion ? { scale: 1, x: 0 } : { scale: 1.14, x: direction === 1 ? 12 : -12 }}
|
|
transition={reduceMotion ? { duration: 0 } : { duration: 14, ease: 'linear' }}
|
|
className="absolute inset-0 bg-cover bg-top will-change-transform"
|
|
style={{ backgroundImage: `url(${backdrop})` }}
|
|
/>
|
|
)}
|
|
|
|
{/* Multi-direction gradient masks for legibility */}
|
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/55 to-transparent" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/40 to-transparent" />
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_75%_30%,rgba(0,0,0,0)_0%,rgba(0,0,0,0.45)_75%)]" />
|
|
|
|
{/* Content */}
|
|
<div className="relative h-full flex items-end pb-20 px-7">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.18 }}
|
|
className="max-w-2xl"
|
|
>
|
|
{/* Eyebrow */}
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-accent/12 border border-accent/30 text-accent text-[10px] font-semibold uppercase tracking-[0.14em]">
|
|
<Flame size={10} strokeWidth={2.5} fill="currentColor" />
|
|
Featured
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
|
<TypeIcon size={10} className="text-accent" stroke={2} />
|
|
{isSeries ? 'Series' : 'Movie'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Title - logo if available, else display text */}
|
|
{logo ? (
|
|
<img
|
|
src={logo}
|
|
alt={title}
|
|
className="block h-auto max-h-32 md:max-h-36 max-w-[560px] w-auto mb-4 drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
|
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
) : (
|
|
<h1 className="font-display text-5xl md:text-6xl font-bold text-white leading-[0.95] tracking-tight mb-4 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)]">
|
|
{title}
|
|
</h1>
|
|
)}
|
|
|
|
{tagline && (
|
|
<p className="text-white/70 text-[15px] italic mb-3 max-w-xl font-display drop-shadow-sm">
|
|
"{tagline}"
|
|
</p>
|
|
)}
|
|
|
|
{/* Meta line */}
|
|
<div className="flex items-center gap-2.5 text-[12px] text-white/75 mb-3 flex-wrap font-medium">
|
|
{year && <span className="tabular-nums">{year}</span>}
|
|
{year && (rating || runtime) && <Dot />}
|
|
{rating && (
|
|
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
|
|
{rating}
|
|
</span>
|
|
)}
|
|
{rating && runtime && <Dot />}
|
|
{runtime && (
|
|
<span className="inline-flex items-center gap-1 tabular-nums">
|
|
<Clock size={11} stroke={2} />
|
|
{runtime}
|
|
</span>
|
|
)}
|
|
{community != null && community > 0 && (
|
|
<>
|
|
<Dot />
|
|
<span className="inline-flex items-center gap-1">
|
|
<Star size={11} className="text-accent" fill="currentColor" stroke={0} />
|
|
<span className="tabular-nums">{community.toFixed(1)}</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Genres */}
|
|
{genres.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mb-5">
|
|
{genres.map(g => (
|
|
<span
|
|
key={g}
|
|
className="inline-flex items-center h-6 px-2.5 bg-white/8 backdrop-blur text-white/85 text-[11px] rounded-full border border-white/8"
|
|
>
|
|
{g}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Overview */}
|
|
{overview && (
|
|
<p className="text-[14px] text-white/85 leading-[1.65] mb-6 line-clamp-3 max-w-xl drop-shadow-sm">
|
|
{overview}
|
|
</p>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2.5 flex-wrap">
|
|
<button
|
|
onClick={() => item.Id && navigate(`/play/${item.Id}${hasProgress ? '?resume=true' : ''}`)}
|
|
className="group relative h-11 px-6 bg-white text-void rounded-lg flex items-center gap-2 text-[14px] font-semibold transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-black/40 focus-ring"
|
|
>
|
|
<Play size={15} stroke={0} fill="currentColor" />
|
|
{hasProgress ? 'Resume' : 'Play'}
|
|
{hasProgress && (
|
|
<span className="absolute -bottom-1 left-2 right-2 h-[2px] bg-void/15 rounded-full overflow-hidden">
|
|
<span className="block h-full bg-accent" style={{ width: `${progress}%` }} />
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
|
className="h-11 px-5 bg-white/10 text-white border border-white/20 backdrop-blur rounded-lg flex items-center gap-2 text-[14px] font-medium transition-all duration-200 hover:bg-white/15 hover:border-white/30 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
|
>
|
|
<Info size={15} stroke={2} />
|
|
More info
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|