diff --git a/src/pages/home/home-hero.tsx b/src/pages/home/home-hero.tsx new file mode 100644 index 0000000..950baa6 --- /dev/null +++ b/src/pages/home/home-hero.tsx @@ -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 +}) { + 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 ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + role="region" + aria-label="Featured titles" + aria-roledescription="carousel" + > + + + + + {/* Edge gradients to keep arrows + title legible regardless of backdrop */} +
+
+ + {/* Bottom gradient blend into rows */} +
+ + {/* Manual nav arrows - only when more than 1 slide */} + {total > 1 && ( + <> + + + + )} + + {/* Indicators - Netflix-style filling progress bars */} + {total > 1 && ( +
+
+ {items.map((_, i) => ( + + ))} +
+ + {String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')} + +
+ )} +
+ ) +} + +function CarouselArrow({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) { + const Icon = side === 'left' ? ChevronLeft : ChevronRight + return ( + + ) +} + +function FeaturedSlide({ + item, + navigate, + direction, +}: { + item: BaseItemDto + navigate: ReturnType + 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 ( + + {/* Ken Burns backdrop - slow scale + horizontal pan, disabled when reduce-motion is on */} + {backdrop && ( + + )} + + {/* Multi-direction gradient masks for legibility */} +
+
+
+ + {/* Content */} +
+ + {/* Eyebrow */} +
+ + + Featured + + + + {isSeries ? 'Series' : 'Movie'} + +
+ + {/* Title - logo if available, else display text */} + {logo ? ( + {title} { (e.target as HTMLImageElement).style.display = 'none' }} + /> + ) : ( +

+ {title} +

+ )} + + {tagline && ( +

+ "{tagline}" +

+ )} + + {/* Meta line */} +
+ {year && {year}} + {year && (rating || runtime) && } + {rating && ( + + {rating} + + )} + {rating && runtime && } + {runtime && ( + + + {runtime} + + )} + {community != null && community > 0 && ( + <> + + + + {community.toFixed(1)} + + + )} +
+ + {/* Genres */} + {genres.length > 0 && ( +
+ {genres.map(g => ( + + {g} + + ))} +
+ )} + + {/* Overview */} + {overview && ( +

+ {overview} +

+ )} + + {/* Actions */} +
+ + + +
+
+
+ + ) +} diff --git a/src/pages/home/home-utils.tsx b/src/pages/home/home-utils.tsx new file mode 100644 index 0000000..167c188 --- /dev/null +++ b/src/pages/home/home-utils.tsx @@ -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(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 · +} + +export function HomeSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map(i => ( +
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, j) => ( +
+
+
+
+
+ ))} +
+
+ ))} +
+
+ ) +} + +export function EmptyHome() { + return ( +
+
+
+
+ +
+
+

Welcome to Jellyfin

+

+ Your library is quiet. Add movies, shows, or music to your Jellyfin server and they'll appear here. +

+
+ ) +} diff --git a/src/pages/home/rows/brands.tsx b/src/pages/home/rows/brands.tsx new file mode 100644 index 0000000..766c2e3 --- /dev/null +++ b/src/pages/home/rows/brands.tsx @@ -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 ( +
+ + {STUDIOS.map(s => ( + + ))} +
+ ) +} + +/** + * "On the networks" - one row per major TV network. + */ +export function NetworkRows() { + const hasTmdb = useHasTmdbKey() + if (!hasTmdb) return null + return ( +
+ + {NETWORKS.map(n => ( + + ))} +
+ ) +} + +function BrandSectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) { + return ( +
+
+ + {eyebrow} +
+

{title}

+

{subtitle}

+
+ ) +} + +/** + * Discover canon - renders bundled canon lists (AFI, Sight & Sound, IMDb top). + */ +export function DiscoverCanonSection() { + const hasTmdb = useHasTmdbKey() + if (!hasTmdb) return null + return ( +
+ + {CANON_LISTS.map(list => ( + + ))} +
+ ) +} + +/** + * 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 => ( + + ))} +
+ +
+ setAddOpen(false)} /> + + ) +} diff --git a/src/pages/home/rows/composite.tsx b/src/pages/home/rows/composite.tsx new file mode 100644 index 0000000..07882a2 --- /dev/null +++ b/src/pages/home/rows/composite.tsx @@ -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 && } + {prefs.home.show.becauseYouWatched && } + {prefs.home.show.personSpotlights && } + {prefs.home.show.trendingToday && } + {prefs.home.show.criticallyAcclaimed && } + {prefs.home.show.genreDeepDive && } + {prefs.home.show.cultClassics && } + {prefs.home.show.yearEndBestOf && } + {prefs.home.show.foreignCinema && } + {prefs.home.show.documentaryPicks && } + {prefs.home.show.awardWinnersMissing && } + {prefs.home.show.studios && } + {prefs.home.show.networks && } + {prefs.home.show.discoverCanon && } + {prefs.home.show.letterboxdLists && } + {prefs.home.show.comingSoon && } + {prefs.home.show.moodPicker && } + {prefs.home.show.smartShelves && } + {prefs.home.show.timeOfDay && } + {prefs.home.show.untouched && } + {prefs.home.show.hiddenGems && } + + ) +} + +/** + * 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(() => { + 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 ( +
+
+

What's your mood?

+

Tap one to refresh the row below

+
+
+ {MOODS.map(m => { + const on = active === m.key + return ( + + ) + })} +
+ {mood && data?.Items && data.Items.length > 0 && ( +
+ +
+ )} +
+ ) +} + +/** + * 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 => ( + + ))} +
+ +
+ setWizardOpen(false)} /> + + ) +} diff --git a/src/pages/home/rows/discovery.tsx b/src/pages/home/rows/discovery.tsx new file mode 100644 index 0000000..c506d30 --- /dev/null +++ b/src/pages/home/rows/discovery.tsx @@ -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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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), + ) + const tvDiscover = useTmdbDiscoverTv( + tvGenreId + ? { + with_genres: String(tvGenreId), + 'vote_count.gte': '500', + sort_by: 'vote_average.desc', + } + : ({} as Record), + ) + + 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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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(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
{near ? : null}
+} + +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 ( + + ) +} + +/** + * 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 ( + + ) +} diff --git a/src/pages/home/rows/library.tsx b/src/pages/home/rows/library.tsx new file mode 100644 index 0000000..5263dae --- /dev/null +++ b/src/pages/home/rows/library.tsx @@ -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 +} + +/** + * 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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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 +} + +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 +} + +/** + * "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 ( + + ) +} + +/** + * 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 && ( + + )} + {spotlights.actor && ( + + )} + + ) +} + +/** + * Watchlist row. Reads from the user's "Watchlist" Jellyfin playlist. + */ +export function WatchlistRow() { + const { items } = useWatchlist() + if (items.length === 0) return null + return ( + + ) +}