Files
jellybloom/src/pages/home/home-hero.tsx
T

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