home page rows
This commit is contained in:
@@ -0,0 +1,340 @@
|
|||||||
|
import { useEffect, useRef, useState } 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
|
||||||
|
setIndex(i => (i - 1 + total) % total)
|
||||||
|
}
|
||||||
|
function goNext() {
|
||||||
|
directionRef.current = 1
|
||||||
|
setIndex(i => (i + 1) % total)
|
||||||
|
}
|
||||||
|
function goTo(i: number) {
|
||||||
|
directionRef.current = i > index ? 1 : -1
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Compass } from '../../lib/icons'
|
||||||
|
|
||||||
|
export interface TimeSlot {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
genres: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickTimeOfDaySlot(): TimeSlot {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
if (hour >= 5 && hour < 12) {
|
||||||
|
return {
|
||||||
|
title: 'A gentle start',
|
||||||
|
subtitle: 'Morning picks - light comedy, animation, and feel-good',
|
||||||
|
genres: ['Comedy', 'Animation', 'Family'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hour >= 12 && hour < 17) {
|
||||||
|
return {
|
||||||
|
title: 'Afternoon adventure',
|
||||||
|
subtitle: 'Action, sci-fi, and adventure for the daylight hours',
|
||||||
|
genres: ['Action', 'Adventure', 'Science Fiction'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hour >= 17 && hour < 22) {
|
||||||
|
return {
|
||||||
|
title: 'Evening watch',
|
||||||
|
subtitle: 'Prestige drama and thrillers for prime time',
|
||||||
|
genres: ['Drama', 'Thriller', 'Mystery'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'After hours',
|
||||||
|
subtitle: 'Horror, noir, and the dark stuff for late nights',
|
||||||
|
genres: ['Horror', 'Crime', 'Thriller'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day-bucketed shuffle - rotates a few times a day, stable within a session. */
|
||||||
|
export function dailyBucket(): number {
|
||||||
|
// Changes every 6 hours so the order rotates a few times a day
|
||||||
|
return Math.floor(Date.now() / (6 * 3600 * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seededShuffle<T>(arr: T[], seed: number): T[] {
|
||||||
|
const out = [...arr]
|
||||||
|
let s = seed | 0
|
||||||
|
const random = () => {
|
||||||
|
s = (s + 0x6D2B79F5) | 0
|
||||||
|
let t = Math.imul(s ^ (s >>> 15), 1 | s)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1))
|
||||||
|
;[out[i], out[j]] = [out[j], out[i]]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dot() {
|
||||||
|
return <span className="text-white/30">·</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<div className="relative h-[60vh] min-h-[440px] -mt-14 mb-2 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 skeleton" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/50 to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-void via-transparent to-transparent" />
|
||||||
|
<div className="relative h-full flex items-end pb-16 px-7">
|
||||||
|
<div className="max-w-2xl space-y-3 w-full">
|
||||||
|
<div className="skeleton h-5 w-24 rounded" />
|
||||||
|
<div className="skeleton h-12 w-2/3 rounded" />
|
||||||
|
<div className="skeleton h-4 w-1/3 rounded" />
|
||||||
|
<div className="skeleton h-12 w-1/2 rounded mt-4" />
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<div className="skeleton h-10 w-24 rounded-lg" />
|
||||||
|
<div className="skeleton h-10 w-28 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="-mt-4 relative z-10 space-y-10">
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="px-7 mb-3.5">
|
||||||
|
<div className="skeleton h-5 w-40 rounded mb-1.5" />
|
||||||
|
<div className="skeleton h-3 w-56 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="px-7 flex gap-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, j) => (
|
||||||
|
<div key={j} className="shrink-0 w-[160px]">
|
||||||
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||||
|
<div className="skeleton h-3 w-3/4 mt-2 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 mt-1 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyHome() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-6">
|
||||||
|
<div className="relative w-20 h-20 mb-5">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-2xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-accent/20 to-accent/5 ring-1 ring-accent/20 grid place-items-center">
|
||||||
|
<Compass size={28} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[20px] font-semibold text-text-1 mb-1.5 tracking-tight">Welcome to Jellyfin</h2>
|
||||||
|
<p className="text-[13px] text-text-3 max-w-md leading-relaxed">
|
||||||
|
Your library is quiet. Add movies, shows, or music to your Jellyfin server and they'll appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Filter } from '../../../lib/icons'
|
||||||
|
import { useHasTmdbKey } from '../../../hooks/use-external'
|
||||||
|
import { CANON_LISTS } from '../../../lib/canon-lists'
|
||||||
|
import { STUDIOS, NETWORKS } from '../../../lib/studios-and-networks'
|
||||||
|
import BrandRow from '../../../components/ui/BrandRow'
|
||||||
|
import CanonListRow from '../../../components/ui/CanonListRow'
|
||||||
|
import LetterboxdListRow from '../../../components/ui/LetterboxdListRow'
|
||||||
|
import LetterboxdAddModal from '../../../components/ui/LetterboxdAddModal'
|
||||||
|
import { useLetterboxdLists } from '../../../stores/letterboxd-lists-store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "From the studios" - one row per major film studio.
|
||||||
|
*/
|
||||||
|
export function StudioRows() {
|
||||||
|
const hasTmdb = useHasTmdbKey()
|
||||||
|
if (!hasTmdb) return null
|
||||||
|
return (
|
||||||
|
<section className="mb-4 mt-4">
|
||||||
|
<BrandSectionHeader eyebrow="Studios" title="From the studios" subtitle="Films grouped by the company that made them" />
|
||||||
|
{STUDIOS.map(s => (
|
||||||
|
<BrandRow key={s.id} brandId={s.id} label={s.label} subtitle={s.blurb} kind="movie" />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "On the networks" - one row per major TV network.
|
||||||
|
*/
|
||||||
|
export function NetworkRows() {
|
||||||
|
const hasTmdb = useHasTmdbKey()
|
||||||
|
if (!hasTmdb) return null
|
||||||
|
return (
|
||||||
|
<section className="mb-4 mt-4">
|
||||||
|
<BrandSectionHeader eyebrow="Networks" title="On the networks" subtitle="Shows grouped by where they air" />
|
||||||
|
{NETWORKS.map(n => (
|
||||||
|
<BrandRow key={n.id} brandId={n.id} label={n.label} subtitle={n.blurb} kind="tv" />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandSectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) {
|
||||||
|
return (
|
||||||
|
<div className="px-7 mb-5 pt-2">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[10.5px] font-semibold text-text-3 uppercase tracking-[0.18em]">{eyebrow}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-[20px] font-bold tracking-tight text-text-1 leading-tight">{title}</h2>
|
||||||
|
<p className="text-[12px] text-text-3 mt-0.5">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover canon - renders bundled canon lists (AFI, Sight & Sound, IMDb top).
|
||||||
|
*/
|
||||||
|
export function DiscoverCanonSection() {
|
||||||
|
const hasTmdb = useHasTmdbKey()
|
||||||
|
if (!hasTmdb) return null
|
||||||
|
return (
|
||||||
|
<section className="mb-4 mt-4">
|
||||||
|
<BrandSectionHeader eyebrow="Canon" title="Discover canon" subtitle="Bundled lists from AFI, Sight & Sound, and IMDb" />
|
||||||
|
{CANON_LISTS.map(list => (
|
||||||
|
<CanonListRow key={list.id} list={list} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letterboxd lists. Each saved list URL renders as its own row lazy-mounted
|
||||||
|
* on scroll. The trailing affordance opens a modal to paste a new URL.
|
||||||
|
*/
|
||||||
|
export function LetterboxdListsSection() {
|
||||||
|
const hasTmdb = useHasTmdbKey()
|
||||||
|
const lists = useLetterboxdLists(s => s.lists)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
if (!hasTmdb) return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lists.map(saved => (
|
||||||
|
<LetterboxdListRow key={saved.url} saved={saved} />
|
||||||
|
))}
|
||||||
|
<div className="px-7 mb-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-full bg-elevated/50 ring-1 ring-border hover:ring-accent/40 hover:text-accent text-[12.5px] text-text-2 tracking-tight transition focus-ring"
|
||||||
|
>
|
||||||
|
<Filter size={13} />
|
||||||
|
{lists.length === 0 ? 'Add a Letterboxd list' : 'Add another Letterboxd list'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LetterboxdAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Filter } from '../../../lib/icons'
|
||||||
|
import { useLibraryItems } from '../../../hooks/use-jellyfin'
|
||||||
|
import ContentRow from '../../../components/ui/ContentRow'
|
||||||
|
import SmartShelfRow from '../../../components/ui/SmartShelfRow'
|
||||||
|
import SmartShelfWizard from '../../../components/ui/SmartShelfWizard'
|
||||||
|
import LazyMount from '../../../components/ui/LazyMount'
|
||||||
|
import { useSmartShelves } from '../../../stores/smart-shelves-store'
|
||||||
|
import { usePreferencesStore } from '../../../stores/preferences-store'
|
||||||
|
import {
|
||||||
|
BecauseYouWatchedRow,
|
||||||
|
HiddenGemsRow,
|
||||||
|
PersonSpotlights,
|
||||||
|
TimeOfDayRow,
|
||||||
|
UntouchedRow,
|
||||||
|
WatchlistRow,
|
||||||
|
} from './library'
|
||||||
|
import {
|
||||||
|
AwardWinnersMissingRow,
|
||||||
|
ComingSoonRow,
|
||||||
|
CriticallyAcclaimedMissingRow,
|
||||||
|
CultClassicsRow,
|
||||||
|
DocumentaryPicksRow,
|
||||||
|
ForeignCinemaRow,
|
||||||
|
GenreDeepDiveRow,
|
||||||
|
TrendingTodayRow,
|
||||||
|
YearEndBestOfRow,
|
||||||
|
} from './discovery'
|
||||||
|
import {
|
||||||
|
DiscoverCanonSection,
|
||||||
|
LetterboxdListsSection,
|
||||||
|
NetworkRows,
|
||||||
|
StudioRows,
|
||||||
|
} from './brands'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders all the configurable home-page sections in order, gating each on
|
||||||
|
* its individual pref. Keeps the JSX tree at the top of HomePage tidy while
|
||||||
|
* letting users hide whatever they don't want via Settings.
|
||||||
|
*/
|
||||||
|
export function HomeSections() {
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
// Each row gets wrapped in LazyMount so we don't fire 25+ data queries +
|
||||||
|
// 400+ poster cards on first paint. The watchlist row stays eager - it's
|
||||||
|
// typically the first thing below the fold and users want it instant.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{prefs.home.show.watchlist && <WatchlistRow />}
|
||||||
|
{prefs.home.show.becauseYouWatched && <LazyMount><BecauseYouWatchedRow /></LazyMount>}
|
||||||
|
{prefs.home.show.personSpotlights && <LazyMount><PersonSpotlights /></LazyMount>}
|
||||||
|
{prefs.home.show.trendingToday && <LazyMount><TrendingTodayRow /></LazyMount>}
|
||||||
|
{prefs.home.show.criticallyAcclaimed && <LazyMount><CriticallyAcclaimedMissingRow /></LazyMount>}
|
||||||
|
{prefs.home.show.genreDeepDive && <LazyMount><GenreDeepDiveRow /></LazyMount>}
|
||||||
|
{prefs.home.show.cultClassics && <LazyMount><CultClassicsRow /></LazyMount>}
|
||||||
|
{prefs.home.show.yearEndBestOf && <LazyMount><YearEndBestOfRow /></LazyMount>}
|
||||||
|
{prefs.home.show.foreignCinema && <LazyMount><ForeignCinemaRow /></LazyMount>}
|
||||||
|
{prefs.home.show.documentaryPicks && <LazyMount><DocumentaryPicksRow /></LazyMount>}
|
||||||
|
{prefs.home.show.awardWinnersMissing && <LazyMount><AwardWinnersMissingRow /></LazyMount>}
|
||||||
|
{prefs.home.show.studios && <LazyMount><StudioRows /></LazyMount>}
|
||||||
|
{prefs.home.show.networks && <LazyMount><NetworkRows /></LazyMount>}
|
||||||
|
{prefs.home.show.discoverCanon && <LazyMount><DiscoverCanonSection /></LazyMount>}
|
||||||
|
{prefs.home.show.letterboxdLists && <LazyMount><LetterboxdListsSection /></LazyMount>}
|
||||||
|
{prefs.home.show.comingSoon && <LazyMount><ComingSoonRow /></LazyMount>}
|
||||||
|
{prefs.home.show.moodPicker && <LazyMount><MoodSection /></LazyMount>}
|
||||||
|
{prefs.home.show.smartShelves && <LazyMount><SmartShelvesSection /></LazyMount>}
|
||||||
|
{prefs.home.show.timeOfDay && <LazyMount><TimeOfDayRow /></LazyMount>}
|
||||||
|
{prefs.home.show.untouched && <LazyMount><UntouchedRow /></LazyMount>}
|
||||||
|
{prefs.home.show.hiddenGems && <LazyMount><HiddenGemsRow /></LazyMount>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mood selector. The picker drives a single dynamic row beneath it. Selection
|
||||||
|
* is persisted to localStorage so the user lands on their last mood.
|
||||||
|
*/
|
||||||
|
const MOODS: Array<{ key: string; label: string; genres: string[] }> = [
|
||||||
|
{ key: 'cozy', label: 'Cozy', genres: ['Family', 'Animation', 'Comedy'] },
|
||||||
|
{ key: 'mind-bending', label: 'Mind-bending', genres: ['Mystery', 'Science Fiction', 'Thriller'] },
|
||||||
|
{ key: 'light', label: 'Light', genres: ['Comedy', 'Family', 'Animation'] },
|
||||||
|
{ key: 'heavy', label: 'Heavy', genres: ['Drama', 'War', 'History'] },
|
||||||
|
{ key: 'funny', label: 'Funny', genres: ['Comedy'] },
|
||||||
|
{ key: 'tense', label: 'Tense', genres: ['Thriller', 'Horror', 'Crime'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function MoodSection() {
|
||||||
|
const [active, setActive] = useState<string | null>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return localStorage.getItem('home_mood')
|
||||||
|
})
|
||||||
|
const mood = MOODS.find(m => m.key === active) || null
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
genres: mood?.genres,
|
||||||
|
sortBy: ['Random'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 16,
|
||||||
|
enabled: !!mood,
|
||||||
|
})
|
||||||
|
function pick(key: string) {
|
||||||
|
const next = active === key ? null : key
|
||||||
|
setActive(next)
|
||||||
|
try {
|
||||||
|
if (next) localStorage.setItem('home_mood', next)
|
||||||
|
else localStorage.removeItem('home_mood')
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="mb-10 px-7">
|
||||||
|
<div className="mb-3.5">
|
||||||
|
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight">What's your mood?</h2>
|
||||||
|
<p className="text-[12px] text-text-3 mt-0.5">Tap one to refresh the row below</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||||
|
{MOODS.map(m => {
|
||||||
|
const on = active === m.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
onClick={() => pick(m.key)}
|
||||||
|
className={`h-8 px-3.5 rounded-full text-[12px] font-medium tracking-tight transition border ${
|
||||||
|
on
|
||||||
|
? 'bg-accent text-void border-accent'
|
||||||
|
: 'bg-elevated/40 text-text-2 border-border hover:border-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{mood && data?.Items && data.Items.length > 0 && (
|
||||||
|
<div className="-mx-7">
|
||||||
|
<ContentRow
|
||||||
|
title={`${mood.label} picks`}
|
||||||
|
subtitle={mood.genres.join(' · ')}
|
||||||
|
items={data.Items}
|
||||||
|
layoutKey={`mood_${mood.key}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart shelves. Renders one ContentRow per saved rule plus a trailing
|
||||||
|
* "New smart shelf" affordance.
|
||||||
|
*/
|
||||||
|
function SmartShelvesSection() {
|
||||||
|
const shelves = useSmartShelves(s => s.shelves)
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{shelves.map(s => (
|
||||||
|
<SmartShelfRow key={s.id} rule={s} />
|
||||||
|
))}
|
||||||
|
<div className="px-7 mb-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setWizardOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-full bg-elevated/50 ring-1 ring-border hover:ring-accent/40 hover:text-accent text-[12.5px] text-text-2 tracking-tight transition focus-ring"
|
||||||
|
>
|
||||||
|
<Filter size={13} />
|
||||||
|
{shelves.length === 0 ? 'Create a smart shelf' : 'New smart shelf'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SmartShelfWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
useTmdbTrending,
|
||||||
|
useTmdbUpcoming,
|
||||||
|
useTmdbTopRatedMovies,
|
||||||
|
useTmdbTopRatedTv,
|
||||||
|
useTmdbDiscoverMovies,
|
||||||
|
useTmdbDiscoverTv,
|
||||||
|
} from '../../../hooks/use-tmdb'
|
||||||
|
import { useLibraryByTmdbId, useLibraryItems } from '../../../hooks/use-jellyfin'
|
||||||
|
import { useWikidataAwardWinners } from '../../../hooks/use-external'
|
||||||
|
import { useCanonListResolved } from '../../../hooks/use-canon-list'
|
||||||
|
import { topGenre } from '../../../lib/top-genre'
|
||||||
|
import { tmdbMovieGenreId, tmdbTvGenreId } from '../../../lib/tmdb-genres'
|
||||||
|
import { mapTmdbToJf } from '../../../lib/tmdb-mapping'
|
||||||
|
import { regionForUser } from '../../../lib/format'
|
||||||
|
import ContentRow from '../../../components/ui/ContentRow'
|
||||||
|
import { usePreferencesStore } from '../../../stores/preferences-store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trending today row. Companion to weekly trending - daily catches the buzz,
|
||||||
|
* weekly smooths it out.
|
||||||
|
*/
|
||||||
|
export function TrendingTodayRow() {
|
||||||
|
const trending = useTmdbTrending('all', 'day')
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const raw = trending.data?.results || []
|
||||||
|
const filtered = raw.filter(r => !hideAdult || !r.adult)
|
||||||
|
return mapTmdbToJf(filtered, libraryByTmdbId.data)
|
||||||
|
}, [trending.data, libraryByTmdbId.data, hideAdult])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Trending today"
|
||||||
|
subtitle="What's getting attention right now on TMDB"
|
||||||
|
items={items}
|
||||||
|
layoutKey="trending_today"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Critically acclaimed missing. TMDB top-rated movies and series filtered
|
||||||
|
* against the library - only items NOT in the library show up.
|
||||||
|
*/
|
||||||
|
export function CriticallyAcclaimedMissingRow() {
|
||||||
|
const movies = useTmdbTopRatedMovies()
|
||||||
|
const tv = useTmdbTopRatedTv()
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const lib = libraryByTmdbId.data
|
||||||
|
if (!lib) return []
|
||||||
|
const movieList = (movies.data?.results || [])
|
||||||
|
.map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
.filter(m => !lib.has(String(m.id)))
|
||||||
|
const tvList = (tv.data?.results || [])
|
||||||
|
.map(m => ({ ...m, media_type: 'tv' }))
|
||||||
|
.filter(m => !lib.has(String(m.id)))
|
||||||
|
// Interleave so the row mixes both types instead of 20 movies then 20 series.
|
||||||
|
const out: any[] = []
|
||||||
|
const max = Math.max(movieList.length, tvList.length)
|
||||||
|
for (let i = 0; i < max && out.length < 20; i++) {
|
||||||
|
if (movieList[i]) out.push(movieList[i])
|
||||||
|
if (tvList[i]) out.push(tvList[i])
|
||||||
|
}
|
||||||
|
return mapTmdbToJf(out, lib)
|
||||||
|
}, [movies.data, tv.data, libraryByTmdbId.data])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Critically acclaimed - missing"
|
||||||
|
subtitle="Top-rated on TMDB and not yet in your library"
|
||||||
|
items={items}
|
||||||
|
layoutKey="critically_acclaimed_missing"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genre deep-dive. Tallies the user's recently watched library genres, picks
|
||||||
|
* the leader, surfaces TMDB-discover canon in that genre filtered to items
|
||||||
|
* not in the library.
|
||||||
|
*/
|
||||||
|
export function GenreDeepDiveRow() {
|
||||||
|
const recentlyPlayed = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['DatePlayed'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsPlayed'],
|
||||||
|
limit: 30,
|
||||||
|
})
|
||||||
|
const top = topGenre(recentlyPlayed.data?.Items)
|
||||||
|
const movieGenreId = top.primary ? tmdbMovieGenreId(top.primary) : null
|
||||||
|
const tvGenreId = top.primary ? tmdbTvGenreId(top.primary) : null
|
||||||
|
|
||||||
|
const movieDiscover = useTmdbDiscoverMovies(
|
||||||
|
movieGenreId
|
||||||
|
? {
|
||||||
|
with_genres: String(movieGenreId),
|
||||||
|
'vote_count.gte': '1500',
|
||||||
|
sort_by: 'vote_average.desc',
|
||||||
|
}
|
||||||
|
: ({} as Record<string, string>),
|
||||||
|
)
|
||||||
|
const tvDiscover = useTmdbDiscoverTv(
|
||||||
|
tvGenreId
|
||||||
|
? {
|
||||||
|
with_genres: String(tvGenreId),
|
||||||
|
'vote_count.gte': '500',
|
||||||
|
sort_by: 'vote_average.desc',
|
||||||
|
}
|
||||||
|
: ({} as Record<string, string>),
|
||||||
|
)
|
||||||
|
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const lib = libraryByTmdbId.data
|
||||||
|
if (!lib || !top.primary) return []
|
||||||
|
const movies = (movieDiscover.data?.results || [])
|
||||||
|
.filter(m => !lib.has(String(m.id)))
|
||||||
|
.map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
const tv = (tvDiscover.data?.results || [])
|
||||||
|
.filter(m => !lib.has(String(m.id)))
|
||||||
|
.map(m => ({ ...m, media_type: 'tv' }))
|
||||||
|
const out: any[] = []
|
||||||
|
const max = Math.max(movies.length, tv.length)
|
||||||
|
for (let i = 0; i < max && out.length < 18; i++) {
|
||||||
|
if (movies[i]) out.push(movies[i])
|
||||||
|
if (tv[i]) out.push(tv[i])
|
||||||
|
}
|
||||||
|
return mapTmdbToJf(out, lib)
|
||||||
|
}, [movieDiscover.data, tvDiscover.data, libraryByTmdbId.data, top.primary])
|
||||||
|
|
||||||
|
if (!top.primary || items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title={`Your top genre: ${top.primary}`}
|
||||||
|
subtitle="Canon you haven't picked up yet"
|
||||||
|
items={items}
|
||||||
|
layoutKey={`genre_deep_${top.primary}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cult classics. Highly rated films with enough votes to be canon but capped
|
||||||
|
* popularity so we surface the cult tier rather than blockbusters.
|
||||||
|
*/
|
||||||
|
export function CultClassicsRow() {
|
||||||
|
const discover = useTmdbDiscoverMovies({
|
||||||
|
'vote_count.gte': '10000',
|
||||||
|
'vote_average.gte': '8',
|
||||||
|
'popularity.lte': '20',
|
||||||
|
sort_by: 'vote_average.desc',
|
||||||
|
})
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const raw = (discover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||||
|
}, [discover.data, libraryByTmdbId.data])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Cult classics"
|
||||||
|
subtitle="Highly rated films flying under the radar"
|
||||||
|
items={items}
|
||||||
|
layoutKey="cult_classics"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Year-end best-ofs. Top-rated movies released in the current year.
|
||||||
|
*/
|
||||||
|
export function YearEndBestOfRow() {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
const discover = useTmdbDiscoverMovies({
|
||||||
|
primary_release_year: String(year),
|
||||||
|
'vote_count.gte': '200',
|
||||||
|
sort_by: 'vote_average.desc',
|
||||||
|
})
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const raw = (discover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||||
|
}, [discover.data, libraryByTmdbId.data])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title={`Top movies of ${year}`}
|
||||||
|
subtitle="The best-rated theatrical and streaming releases this year"
|
||||||
|
items={items}
|
||||||
|
layoutKey={`year_end_${year}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreign cinema. Drops English originals client-side from the broad
|
||||||
|
* rated-discover pool.
|
||||||
|
*/
|
||||||
|
export function ForeignCinemaRow() {
|
||||||
|
const discover = useTmdbDiscoverMovies({
|
||||||
|
'vote_count.gte': '500',
|
||||||
|
sort_by: 'vote_average.desc',
|
||||||
|
})
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const raw = (discover.data?.results || [])
|
||||||
|
.filter((m: any) => m.original_language && m.original_language !== 'en')
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||||
|
}, [discover.data, libraryByTmdbId.data])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Foreign cinema"
|
||||||
|
subtitle="Top-rated non-English films"
|
||||||
|
items={items}
|
||||||
|
layoutKey="foreign_cinema"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documentary picks. Genre 99 = Documentary in TMDB.
|
||||||
|
*/
|
||||||
|
export function DocumentaryPicksRow() {
|
||||||
|
const discover = useTmdbDiscoverMovies({
|
||||||
|
with_genres: '99',
|
||||||
|
'vote_count.gte': '300',
|
||||||
|
sort_by: 'vote_average.desc',
|
||||||
|
})
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const raw = (discover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||||
|
}, [discover.data, libraryByTmdbId.data])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Documentary picks"
|
||||||
|
subtitle="The best-rated docs on TMDB"
|
||||||
|
items={items}
|
||||||
|
layoutKey="documentary_picks"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award winners you're missing. Wikidata Q-id 102427 is "Academy Award for
|
||||||
|
* Best Picture"; the SPARQL gives us all winners with TMDB cross-refs.
|
||||||
|
*/
|
||||||
|
const ACADEMY_AWARD_BEST_PICTURE = 'Q102427'
|
||||||
|
|
||||||
|
export function AwardWinnersMissingRow() {
|
||||||
|
// Lazy-mount on scroll - sits deep in the home page, don't fire 18 TMDB
|
||||||
|
// lookups on first paint.
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [near, setNear] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const el = containerRef.current
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
setNear(true)
|
||||||
|
obs.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
)
|
||||||
|
obs.observe(el)
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
return <div ref={containerRef}>{near ? <AwardWinnersMissingRowMounted /> : null}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AwardWinnersMissingRowMounted() {
|
||||||
|
const winners = useWikidataAwardWinners(ACADEMY_AWARD_BEST_PICTURE)
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
// Wikidata gives us TMDB ids + labels but no posters. Resolve them so the
|
||||||
|
// row renders real artwork instead of letter placeholders.
|
||||||
|
const missingIds = useMemo(() => {
|
||||||
|
const list = winners.data || []
|
||||||
|
const lib = libraryByTmdbId.data
|
||||||
|
if (!lib) return [] as number[]
|
||||||
|
return list
|
||||||
|
.filter(w => w.tmdbId && w.type === 'movie' && !lib.has(w.tmdbId))
|
||||||
|
.slice(0, 18)
|
||||||
|
.map(w => Number(w.tmdbId))
|
||||||
|
.filter(n => Number.isFinite(n))
|
||||||
|
}, [winners.data, libraryByTmdbId.data])
|
||||||
|
const { items: tmdbItems } = useCanonListResolved(missingIds)
|
||||||
|
const items = useMemo(
|
||||||
|
() => mapTmdbToJf(tmdbItems, libraryByTmdbId.data),
|
||||||
|
[tmdbItems, libraryByTmdbId.data],
|
||||||
|
)
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Best Picture winners you're missing"
|
||||||
|
subtitle="Academy Awards canon, filtered against your library"
|
||||||
|
items={items}
|
||||||
|
layoutKey="award_winners_missing"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coming soon row. TMDB upcoming movies, region-filtered.
|
||||||
|
*/
|
||||||
|
export function ComingSoonRow() {
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
const region = prefs.region || regionForUser()
|
||||||
|
const upcoming = useTmdbUpcoming(region)
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const raw = (upcoming.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||||
|
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
||||||
|
}, [upcoming.data, libraryByTmdbId.data])
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Coming soon"
|
||||||
|
subtitle={`New theatrical and streaming releases${region ? ` in ${region}` : ''}`}
|
||||||
|
items={items}
|
||||||
|
layoutKey="coming_soon"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useLibraryItems, useLibraryByTmdbId } from '../../../hooks/use-jellyfin'
|
||||||
|
import { useTmdbMovie, useTmdbTvShow } from '../../../hooks/use-tmdb'
|
||||||
|
import { useWatchlist } from '../../../hooks/use-watchlist'
|
||||||
|
import { usePersonSpotlights } from '../../../hooks/use-person-spotlights'
|
||||||
|
import { mapTmdbToJf } from '../../../lib/tmdb-mapping'
|
||||||
|
import ContentRow from '../../../components/ui/ContentRow'
|
||||||
|
import PersonSpotlightRow from '../../../components/ui/PersonSpotlightRow'
|
||||||
|
import { pickTimeOfDaySlot } from '../home-utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library decade rows. Buckets the user's library into the chosen decade.
|
||||||
|
* Hidden when a decade has fewer than 6 items so it doesn't feel sparse.
|
||||||
|
*/
|
||||||
|
export function DecadeRow({ decade }: { decade: number }) {
|
||||||
|
const years = Array.from({ length: 10 }, (_, i) => decade + i)
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
years,
|
||||||
|
sortBy: ['Random'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 18,
|
||||||
|
})
|
||||||
|
const items = data?.Items || []
|
||||||
|
if (items.length < 6) return null
|
||||||
|
const decadeLabel = `${decade}s`
|
||||||
|
const subtitle =
|
||||||
|
decade <= 1980
|
||||||
|
? 'Vintage finds from your library'
|
||||||
|
: decade === 1990
|
||||||
|
? 'A nineties revival'
|
||||||
|
: decade === 2000
|
||||||
|
? 'Y2K and after'
|
||||||
|
: decade === 2010
|
||||||
|
? 'The streaming-era classics'
|
||||||
|
: 'Recent canon'
|
||||||
|
return <ContentRow title={decadeLabel} subtitle={subtitle} items={items} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hidden gems. Library items the user has never opened, sorted by community
|
||||||
|
* rating. The 7.5 threshold keeps the bar high enough that the row feels
|
||||||
|
* curated.
|
||||||
|
*/
|
||||||
|
export function HiddenGemsRow() {
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['CommunityRating'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
minCommunityRating: 7.5,
|
||||||
|
filters: ['IsUnplayed'],
|
||||||
|
limit: 16,
|
||||||
|
})
|
||||||
|
const items = data?.Items || []
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Hidden gems"
|
||||||
|
subtitle="Highly rated and still unwatched in your library"
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently added items the user hasn't played at all. Distinct from
|
||||||
|
* "Recently added" (any recent) and from "Continue watching" (already
|
||||||
|
* started).
|
||||||
|
*/
|
||||||
|
export function UntouchedRow() {
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['DateCreated'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsUnplayed'],
|
||||||
|
limit: 14,
|
||||||
|
})
|
||||||
|
const items = data?.Items || []
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Added but not yet started"
|
||||||
|
subtitle="New arrivals you haven't opened"
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-of-day picker. The slot rotates between four moods based on local
|
||||||
|
* time: morning calm, afternoon adventure, evening drama, late-night dark.
|
||||||
|
*/
|
||||||
|
export function TimeOfDayRow() {
|
||||||
|
const slot = pickTimeOfDaySlot()
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
genres: slot.genres,
|
||||||
|
sortBy: ['Random'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 14,
|
||||||
|
})
|
||||||
|
const items = data?.Items || []
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return <ContentRow title={slot.title} subtitle={slot.subtitle} items={items} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenreRow({ genre, subtitle }: { genre: string; subtitle: string }) {
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
genres: [genre],
|
||||||
|
sortBy: ['Random'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 16,
|
||||||
|
})
|
||||||
|
const items = data?.Items || []
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return <ContentRow title={genre} subtitle={subtitle} items={items} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Because you watched X". Picks the user's most recent finished item with
|
||||||
|
* a TMDB id, fetches TMDB recommendations, filters out library items.
|
||||||
|
*/
|
||||||
|
export function BecauseYouWatchedRow() {
|
||||||
|
const recentlyPlayed = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['DatePlayed'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsPlayed'],
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
const seed = useMemo(() => {
|
||||||
|
const list = recentlyPlayed.data?.Items || []
|
||||||
|
return list.find(it => !!(it.ProviderIds?.Tmdb)) || null
|
||||||
|
}, [recentlyPlayed.data])
|
||||||
|
|
||||||
|
const tmdbId = seed?.ProviderIds?.Tmdb ? Number(seed.ProviderIds.Tmdb) : null
|
||||||
|
const isSeries = seed?.Type === 'Series'
|
||||||
|
const movieFull = useTmdbMovie(!isSeries ? tmdbId : null)
|
||||||
|
const tvFull = useTmdbTvShow(isSeries ? tmdbId : null)
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const recs = isSeries
|
||||||
|
? tvFull.data?.recommendations?.results
|
||||||
|
: movieFull.data?.recommendations?.results
|
||||||
|
if (!recs) return []
|
||||||
|
const lib = libraryByTmdbId.data
|
||||||
|
const filtered = recs.filter(r => !lib?.has(String(r.id)))
|
||||||
|
return mapTmdbToJf(filtered, lib)
|
||||||
|
}, [movieFull.data, tvFull.data, isSeries, libraryByTmdbId.data])
|
||||||
|
|
||||||
|
if (!seed || items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title={`Because you watched "${seed.Name}"`}
|
||||||
|
subtitle="Picks from TMDB you don't have yet"
|
||||||
|
items={items}
|
||||||
|
layoutKey="because_you_watched"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Director and actor spotlight rows. Aggregates the user's recently watched
|
||||||
|
* library items, surfaces persons watched at least twice. Renders a row each
|
||||||
|
* for the top director and top actor.
|
||||||
|
*/
|
||||||
|
export function PersonSpotlights() {
|
||||||
|
const recentlyPlayed = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['DatePlayed'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsPlayed'],
|
||||||
|
limit: 8,
|
||||||
|
})
|
||||||
|
const spotlights = usePersonSpotlights(recentlyPlayed.data?.Items)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{spotlights.director && (
|
||||||
|
<PersonSpotlightRow
|
||||||
|
personId={spotlights.director.id}
|
||||||
|
name={spotlights.director.name}
|
||||||
|
profilePath={spotlights.director.profile_path}
|
||||||
|
role="director"
|
||||||
|
watchedCount={spotlights.director.count}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{spotlights.actor && (
|
||||||
|
<PersonSpotlightRow
|
||||||
|
personId={spotlights.actor.id}
|
||||||
|
name={spotlights.actor.name}
|
||||||
|
profilePath={spotlights.actor.profile_path}
|
||||||
|
role="actor"
|
||||||
|
watchedCount={spotlights.actor.count}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watchlist row. Reads from the user's "Watchlist" Jellyfin playlist.
|
||||||
|
*/
|
||||||
|
export function WatchlistRow() {
|
||||||
|
const { items } = useWatchlist()
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title="Your watchlist"
|
||||||
|
subtitle="Saved for later"
|
||||||
|
items={items as any}
|
||||||
|
seeAllHref="/playlists"
|
||||||
|
layoutKey="watchlist"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user