home page rows

This commit is contained in:
2026-03-30 12:32:41 +03:00
parent 430981cbf7
commit 8789bd2f4a
6 changed files with 1286 additions and 0 deletions
+99
View File
@@ -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)} />
</>
)
}
+170
View File
@@ -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)} />
</>
)
}
+337
View File
@@ -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"
/>
)
}
+217
View File
@@ -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"
/>
)
}