From 02d65fbeeb8e0c43ae8f020394c9083a3b2a25b8 Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 29 Mar 2026 05:55:12 +0300 Subject: [PATCH] discover components --- src/components/discover/BrowseGrid.tsx | 229 +++++++ src/components/discover/CanonicalLists.tsx | 139 +++++ src/components/discover/DecadeStrip.tsx | 156 +++++ src/components/discover/DiscoverFilters.tsx | 605 +++++++++++++++++++ src/components/discover/LibraryGapFinder.tsx | 198 ++++++ src/components/discover/MoodChips.tsx | 145 +++++ src/components/discover/OnThisDay.tsx | 146 +++++ src/components/discover/Roulette.tsx | 270 +++++++++ src/components/discover/SpotlightHero.tsx | 109 ++++ src/components/discover/TonightHero.tsx | 164 +++++ 10 files changed, 2161 insertions(+) create mode 100644 src/components/discover/BrowseGrid.tsx create mode 100644 src/components/discover/CanonicalLists.tsx create mode 100644 src/components/discover/DecadeStrip.tsx create mode 100644 src/components/discover/DiscoverFilters.tsx create mode 100644 src/components/discover/LibraryGapFinder.tsx create mode 100644 src/components/discover/MoodChips.tsx create mode 100644 src/components/discover/OnThisDay.tsx create mode 100644 src/components/discover/Roulette.tsx create mode 100644 src/components/discover/SpotlightHero.tsx create mode 100644 src/components/discover/TonightHero.tsx diff --git a/src/components/discover/BrowseGrid.tsx b/src/components/discover/BrowseGrid.tsx new file mode 100644 index 0000000..249f00a --- /dev/null +++ b/src/components/discover/BrowseGrid.tsx @@ -0,0 +1,229 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { ChevronRight, X } from '../../lib/icons' +import { + genreVisual, + languageVisual, + studioVisual, + networkVisual, + tileBackground, +} from '../../lib/discover-icons' +import { + GenreRow, + LanguageRow, + StudioRow, + NetworkRow, +} from '../../pages/discover/rows' +import { GENRE_ROWS, LANGUAGE_ROWS } from '../../pages/discover/helpers' +import { STUDIOS, NETWORKS } from '../../lib/studios-and-networks' + +export type BrowseKey = + | { kind: 'genre'; label: string; subtitle: string } + | { kind: 'language'; code: string; label: string; subtitle: string } + | { kind: 'studio'; id: number; label: string; subtitle: string } + | { kind: 'network'; id: number; label: string; subtitle: string } + +function isSameSelection(a: BrowseKey | null, b: BrowseKey | null): boolean { + if (!a || !b) return a === b + if (a.kind !== b.kind) return false + if (a.kind === 'genre' && b.kind === 'genre') return a.label === b.label + if (a.kind === 'language' && b.kind === 'language') return a.code === b.code + if ((a.kind === 'studio' && b.kind === 'studio') || (a.kind === 'network' && b.kind === 'network')) { + return (a as any).id === (b as any).id + } + return false +} + +/** + * "Browse by X" section. Renders a compact grid of tiles for genres / + * languages / studios / networks. Picking a tile expands its full row + * underneath the grid in place - one expansion at a time across the + * three sections, since the parent owns the state. + */ +export function BrowseSection({ + eyebrow, + title, + subtitle, + tiles, + expanded, + onSelect, +}: { + eyebrow: string + title: string + subtitle: string + tiles: BrowseKey[] + expanded: BrowseKey | null + onSelect: (key: BrowseKey | null) => void +}) { + const hasMine = tiles.some(t => isSameSelection(t, expanded)) + return ( +
+
+
+ + + {eyebrow} + +
+

+ {title} +

+

{subtitle}

+
+ + + + + {hasMine && expanded && ( + +
+

+ Now showing - {labelOf(expanded)} +

+ +
+ +
+ )} +
+
+ ) +} + +function tileKey(t: BrowseKey): string { + switch (t.kind) { + case 'genre': return `g:${t.label}` + case 'language': return `l:${t.code}` + case 'studio': return `s:${t.id}` + case 'network': return `n:${t.id}` + } +} + +function labelOf(t: BrowseKey): string { + return t.label +} + +function Tile({ + tile, + active, + onClick, +}: { + tile: BrowseKey + active: boolean + onClick: () => void +}) { + const v = + tile.kind === 'genre' ? genreVisual(tile.label) + : tile.kind === 'language' ? languageVisual(tile.code) + : tile.kind === 'studio' ? studioVisual() + : networkVisual() + const Icon = v.icon + return ( + +
+ +
+

+ {kindLabel(tile.kind)} +

+

+ {tile.label} +

+

+ {tile.subtitle} +

+
+ {active && ( + + )} + + + ) +} + +function kindLabel(k: BrowseKey['kind']): string { + switch (k) { + case 'genre': return 'Genre' + case 'language': return 'Language' + case 'studio': return 'Studio' + case 'network': return 'Network' + } +} + +function ExpansionContent({ tile }: { tile: BrowseKey }) { + switch (tile.kind) { + case 'genre': + return + case 'language': + return + case 'studio': + return + case 'network': + return + } +} + +/** + * Helpers that produce the tile lists for each browse section. + */ + +export function genreTiles(): BrowseKey[] { + return GENRE_ROWS.map(g => ({ kind: 'genre', label: g.label, subtitle: g.subtitle })) +} + +export function languageTiles(): BrowseKey[] { + return LANGUAGE_ROWS.map(l => ({ kind: 'language', code: l.code, label: l.label, subtitle: l.subtitle })) +} + +export function studioTiles(): BrowseKey[] { + return STUDIOS.map(s => ({ kind: 'studio', id: s.id, label: s.label, subtitle: s.blurb || '' })) +} + +export function networkTiles(): BrowseKey[] { + return NETWORKS.map(n => ({ kind: 'network', id: n.id, label: n.label, subtitle: n.blurb || '' })) +} diff --git a/src/components/discover/CanonicalLists.tsx b/src/components/discover/CanonicalLists.tsx new file mode 100644 index 0000000..949844e --- /dev/null +++ b/src/components/discover/CanonicalLists.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react' +import ContentRow from '../ui/ContentRow' +import { useTmdbDiscoverMovies } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { usePreferencesStore } from '../../stores/preferences-store' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { filterToMissing } from '../../pages/discover/helpers' + +interface CanonicalList { + id: string + title: string + subtitle: string + params: Record + /** Optional client-side filter applied on top of TMDB results. */ + extra?: (m: { original_language?: string; vote_count?: number }) => boolean +} + +/** + * Hand-curated approximations of the canonical "you should have seen this" + * lists - AFI / Sight & Sound / Oscar winners - built from TMDB discover + * parameters rather than the volatile user-created /list endpoint. + * + * Each entry is one ContentRow. The rows self-hide via the existing + * filterToMissing pipeline when the user already owns everything in them. + */ +const LISTS: CanonicalList[] = [ + { + id: 'best-picture-winners', + title: 'Best Picture winners', + subtitle: 'Academy Award winners across the decades', + params: { + // TMDB keyword 210024 = "academy award - best picture winner" + with_keywords: '210024', + sort_by: 'vote_average.desc', + 'vote_count.gte': '500', + }, + }, + { + id: 'top-250', + title: 'The canonical 250', + subtitle: 'Films that have settled into the canon - massive vote count, top scores', + params: { + 'vote_count.gte': '10000', + 'vote_average.gte': '8', + sort_by: 'vote_average.desc', + }, + }, + { + id: 'highest-grossing', + title: 'Highest grossing of all time', + subtitle: 'The films that made everyone show up', + params: { + sort_by: 'revenue.desc', + 'vote_count.gte': '500', + }, + }, + { + id: 'modern-classics', + title: 'Modern classics', + subtitle: 'Post-2000 films that already feel essential', + params: { + 'primary_release_date.gte': '2000-01-01', + 'vote_count.gte': '3000', + 'vote_average.gte': '8', + sort_by: 'vote_average.desc', + }, + }, + { + id: 'foreign-canon', + title: 'Foreign cinema canon', + subtitle: 'Non-English films with critical pedigree', + params: { + 'vote_count.gte': '1500', + 'vote_average.gte': '7.8', + sort_by: 'vote_average.desc', + }, + extra: m => !!m.original_language && m.original_language !== 'en', + }, + { + id: 'animation-canon', + title: 'Animation canon', + subtitle: 'Highest-rated animated features across studios', + params: { + with_genres: '16', + 'vote_count.gte': '2000', + 'vote_average.gte': '7.5', + sort_by: 'vote_average.desc', + }, + }, +] + +/** + * Section wrapper for the canonical-lists block. Only renders on the + * movies tab because the TMDB keywords + revenue sorts only make sense + * for films - TV equivalents would need different queries. + */ +export default function CanonicalLists() { + return ( +
+
+
+ + + Canonical lists + +
+

+ The books and ballots +

+

+ Films that show up on every "best of" list - filtered to ones you don't own yet. +

+
+ {LISTS.map(list => ( + + ))} +
+ ) +} + +function CanonicalRow({ list }: { list: CanonicalList }) { + const movies = useTmdbDiscoverMovies(list.params) + const lib = useLibraryByTmdbId() + const hideAdult = usePreferencesStore(s => s.hideAdult) + const items = useMemo(() => { + let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + if (list.extra) raw = raw.filter(list.extra as any) + return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data) + }, [movies.data, lib.data, hideAdult, list]) + if (items.length === 0) return null + return ( + + ) +} diff --git a/src/components/discover/DecadeStrip.tsx b/src/components/discover/DecadeStrip.tsx new file mode 100644 index 0000000..1886f2d --- /dev/null +++ b/src/components/discover/DecadeStrip.tsx @@ -0,0 +1,156 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { useMemo } from 'react' +import ContentRow from '../ui/ContentRow' +import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { usePreferencesStore } from '../../stores/preferences-store' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { filterToMissing } from '../../pages/discover/helpers' + +interface Decade { + label: string + /** Inclusive start + end years for the decade. */ + from: number + to: number + /** A short evocative subtitle used when this decade is expanded. */ + blurb: string +} + +const CURRENT_YEAR = new Date().getFullYear() + +const DECADES: Decade[] = [ + { label: '1950s', from: 1950, to: 1959, blurb: 'Studio system, Westerns, noir, the dawn of widescreen' }, + { label: '1960s', from: 1960, to: 1969, blurb: 'New Wave, counterculture cinema, epic spectacle' }, + { label: '1970s', from: 1970, to: 1979, blurb: 'New Hollywood - auteurs in charge of the asylum' }, + { label: '1980s', from: 1980, to: 1989, blurb: 'Blockbuster era, practical effects, neon everything' }, + { label: '1990s', from: 1990, to: 1999, blurb: 'Indie boom, CGI takes over, prestige cable begins' }, + { label: '2000s', from: 2000, to: 2009, blurb: 'Digital filmmaking, fantasy trilogies, mumblecore' }, + { label: '2010s', from: 2010, to: 2019, blurb: 'Streaming wars, MCU dominance, peak TV' }, + { label: '2020s', from: 2020, to: Math.max(CURRENT_YEAR, 2024), blurb: 'Post-streaming reshuffle, A24 era, global cinema' }, +] + +interface Props { + kind: 'movie' | 'tv' + active: string | null + onChange: (decadeLabel: string | null) => void +} + +/** + * Time-based discovery surface. A horizontal strip of decades sits at + * the top; click one to expand a row of the highest-rated titles from + * that era. Different mental model from mood / genre - the user comes + * in thinking "something from the 80s" and lands directly on it. + */ +export default function DecadeStrip({ kind, active, onChange }: Props) { + return ( +
+
+ + + By decade + +
+
+
    + {DECADES.map(d => ( + onChange(active === d.label ? null : d.label)} + /> + ))} +
+
+ + {active && ( + + d.label === active)!} + kind={kind} + /> + + )} + +
+ ) +} + +function DecadeChip({ + decade, + active, + onClick, +}: { + decade: Decade + active: boolean + onClick: () => void +}) { + const startsAt = decade.from + const isFuture = decade.to > CURRENT_YEAR - 1 && decade.from <= CURRENT_YEAR + return ( +
  • + + + {decade.label} + + + {isFuture ? `${startsAt}-now` : `${startsAt}-${decade.to}`} + + +
  • + ) +} + +function DecadeRow({ decade, kind }: { decade: Decade; kind: 'movie' | 'tv' }) { + const lib = useLibraryByTmdbId() + const hideAdult = usePreferencesStore(s => s.hideAdult) + // The TMDB discover endpoints accept primary_release_year / first_air_date + // ranges. We can use the .gte / .lte form to bracket the decade. + const dateField = kind === 'movie' ? 'primary_release_date' : 'first_air_date' + const params: Record = { + [`${dateField}.gte`]: `${decade.from}-01-01`, + [`${dateField}.lte`]: `${decade.to}-12-31`, + 'vote_count.gte': '500', + 'vote_average.gte': '7', + sort_by: 'vote_average.desc', + } + + const movieQuery = useTmdbDiscoverMovies(kind === 'movie' ? params : {}) + const tvQuery = useTmdbDiscoverTv(kind === 'tv' ? params : {}) + const data = kind === 'movie' ? movieQuery.data : tvQuery.data + + const items = useMemo(() => { + const raw = (data?.results || []).map(m => ({ ...m, media_type: kind })) + return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data) + }, [data, lib.data, hideAdult, kind]) + + if (items.length === 0) return null + return ( + + ) +} diff --git a/src/components/discover/DiscoverFilters.tsx b/src/components/discover/DiscoverFilters.tsx new file mode 100644 index 0000000..4b82355 --- /dev/null +++ b/src/components/discover/DiscoverFilters.tsx @@ -0,0 +1,605 @@ +import { useMemo, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + Filter, + Tv2, + Film as FilmIcon, + RefreshCw, + Star, + Clock, + Calendar, + Languages, + Activity, + Boxes, + ChevronUp, + Library, +} from '../../lib/icons' +import { TMDB_MOVIE_GENRES, TMDB_TV_GENRES } from '../../lib/tmdb-genres' +import { Chip } from '../ui/Chip' +import Select, { type SelectOption } from '../ui/Select' + +export interface DiscoverFilterState { + kind: 'movie' | 'tv' + sortBy: string + genres: number[] + yearFrom: number | null + yearTo: number | null + voteAverageGte: number | null + voteCountGte: number | null + runtimeGte: number | null + runtimeLte: number | null + language: string | null + watchProviders: number[] + watchRegion: string | null + status: string | null + hideOwned: boolean +} + +export const DEFAULT_FILTERS: DiscoverFilterState = { + kind: 'movie', + sortBy: 'popularity.desc', + genres: [], + yearFrom: null, + yearTo: null, + voteAverageGte: null, + voteCountGte: null, + runtimeGte: null, + runtimeLte: null, + language: null, + watchProviders: [], + watchRegion: null, + status: null, + hideOwned: true, +} + +const MOVIE_SORTS: SelectOption[] = [ + { value: 'popularity.desc', label: 'Popularity (high to low)' }, + { value: 'popularity.asc', label: 'Popularity (low to high)' }, + { value: 'vote_average.desc', label: 'Rating (high to low)' }, + { value: 'vote_average.asc', label: 'Rating (low to high)' }, + { value: 'primary_release_date.desc', label: 'Release date (newest)' }, + { value: 'primary_release_date.asc', label: 'Release date (oldest)' }, + { value: 'revenue.desc', label: 'Revenue (high to low)' }, + { value: 'original_title.asc', label: 'Title (A-Z)' }, + { value: 'original_title.desc', label: 'Title (Z-A)' }, +] + +const TV_SORTS: SelectOption[] = [ + { value: 'popularity.desc', label: 'Popularity (high to low)' }, + { value: 'popularity.asc', label: 'Popularity (low to high)' }, + { value: 'vote_average.desc', label: 'Rating (high to low)' }, + { value: 'vote_average.asc', label: 'Rating (low to high)' }, + { value: 'first_air_date.desc', label: 'First air date (newest)' }, + { value: 'first_air_date.asc', label: 'First air date (oldest)' }, + { value: 'name.asc', label: 'Title (A-Z)' }, + { value: 'name.desc', label: 'Title (Z-A)' }, +] + +// Radix Select rejects empty-string values, so the "any" sentinel is a +// magic string we translate to null at the API boundary. +const ANY = '__any__' + +const TV_STATUS_OPTIONS: SelectOption[] = [ + { value: ANY, label: 'Any status' }, + { value: '0', label: 'Returning Series' }, + { value: '1', label: 'Planned' }, + { value: '2', label: 'In Production' }, + { value: '3', label: 'Ended' }, + { value: '4', label: 'Canceled' }, + { value: '5', label: 'Pilot' }, +] + +const LANGUAGES: SelectOption[] = [ + { value: ANY, label: 'Any language' }, + { value: 'en', label: 'English' }, + { value: 'ja', label: 'Japanese' }, + { value: 'ko', label: 'Korean' }, + { value: 'fr', label: 'French' }, + { value: 'es', label: 'Spanish' }, + { value: 'de', label: 'German' }, + { value: 'it', label: 'Italian' }, + { value: 'zh', label: 'Chinese' }, + { value: 'hi', label: 'Hindi' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'ru', label: 'Russian' }, + { value: 'sv', label: 'Swedish' }, + { value: 'da', label: 'Danish' }, + { value: 'nl', label: 'Dutch' }, + { value: 'pl', label: 'Polish' }, + { value: 'tr', label: 'Turkish' }, + { value: 'ar', label: 'Arabic' }, + { value: 'th', label: 'Thai' }, +] + +const VOTE_COUNT_OPTIONS: SelectOption[] = [ + { value: '0', label: 'Any number of votes' }, + { value: '50', label: 'At least 50 votes' }, + { value: '200', label: 'At least 200 votes' }, + { value: '500', label: 'At least 500 votes' }, + { value: '1000', label: 'At least 1,000 votes' }, + { value: '5000', label: 'At least 5,000 votes' }, + { value: '10000', label: 'At least 10,000 votes' }, +] + +const COMMON_PROVIDERS: Array<{ id: number; label: string }> = [ + { id: 8, label: 'Netflix' }, + { id: 9, label: 'Prime Video' }, + { id: 337, label: 'Disney+' }, + { id: 1899, label: 'Max' }, + { id: 350, label: 'Apple TV+' }, + { id: 15, label: 'Hulu' }, + { id: 531, label: 'Paramount+' }, + { id: 386, label: 'Peacock' }, + { id: 283, label: 'Crunchyroll' }, + { id: 2, label: 'Apple TV' }, + { id: 192, label: 'YouTube' }, + { id: 3, label: 'Google Play' }, + { id: 68, label: 'Microsoft' }, +] + +export function countActiveFilters(f: DiscoverFilterState): number { + let n = 0 + if (f.genres.length > 0) n++ + if (f.yearFrom != null || f.yearTo != null) n++ + if (f.voteAverageGte != null) n++ + if (f.voteCountGte != null) n++ + if (f.runtimeGte != null || f.runtimeLte != null) n++ + if (f.language) n++ + if (f.watchProviders.length > 0) n++ + if (f.status) n++ + if (f.sortBy !== DEFAULT_FILTERS.sortBy) n++ + return n +} + +export function hasAnyActiveFilters(f: DiscoverFilterState): boolean { + return countActiveFilters(f) > 0 +} + +interface Props { + filters: DiscoverFilterState + onChange: (next: DiscoverFilterState) => void + region: string +} + +/* ────────────────────────────────────────────────────────────── */ +/* Top toolbar - kind toggle + sort + filter button + hide-owned */ +/* ────────────────────────────────────────────────────────────── */ + +export default function DiscoverFilters({ filters, onChange, region }: Props) { + const [panelOpen, setPanelOpen] = useState(false) + const activeCount = countActiveFilters(filters) + const sorts = filters.kind === 'movie' ? MOVIE_SORTS : TV_SORTS + + const set = (k: K, v: DiscoverFilterState[K]) => { + onChange({ ...filters, [k]: v }) + } + + return ( +
    + {/* Toolbar */} +
    + onChange({ ...filters, kind: k, status: null, runtimeGte: null, runtimeLte: null })} + /> + +
    + + set('voteAverageGte', Number(e.target.value) === 0 ? null : Number(e.target.value))} + aria-label="Minimum rating" + className="slider flex-1" + /> + + {filters.voteAverageGte ?? 0} + +
    + + + } + value={ + filters.voteCountGte != null + ? `${filters.voteCountGte.toLocaleString()}+` + : 'Any' + } + > + set('language', v === ANY ? null : v)} + options={LANGUAGES} + width="w-full" + /> + + + {filters.kind === 'movie' && ( + } + value={`${filters.runtimeGte ?? 0} - ${filters.runtimeLte ?? 240}m`} + > +
    + + {filters.runtimeGte ?? 0}m + + set('runtimeGte', Number(e.target.value) === 0 ? null : Number(e.target.value))} + aria-label="Minimum runtime" + className="slider flex-1" + /> + set('runtimeLte', Number(e.target.value) === 240 ? null : Number(e.target.value))} + aria-label="Maximum runtime" + className="slider flex-1" + /> + + {filters.runtimeLte ?? 240}m + +
    +
    + )} + + {filters.kind === 'tv' && ( + } + value={TV_STATUS_OPTIONS.find(o => o.value === filters.status)?.label as string || 'Any'} + > + { + const n = Number(e.target.value) + onChange(e.target.value === '' || Number.isNaN(n) ? null : n) + }} + placeholder={placeholder} + className="flex-1 h-9 px-3 rounded-md bg-void/50 hover:bg-void/70 ring-1 ring-border focus:ring-accent/50 focus:ring-2 outline-none text-[12.5px] tabular-nums text-text-1 placeholder:text-text-4 transition-all duration-150 font-mono" + /> + ) +} + +/** Convert filter state to TMDB discover query params. */ +export function filtersToTmdbParams(f: DiscoverFilterState): Record { + const p: Record = { sort_by: f.sortBy } + if (f.genres.length > 0) p.with_genres = f.genres.join(',') + if (f.yearFrom != null) { + p[f.kind === 'movie' ? 'primary_release_date.gte' : 'first_air_date.gte'] = `${f.yearFrom}-01-01` + } + if (f.yearTo != null) { + p[f.kind === 'movie' ? 'primary_release_date.lte' : 'first_air_date.lte'] = `${f.yearTo}-12-31` + } + if (f.voteAverageGte != null) p['vote_average.gte'] = String(f.voteAverageGte) + if (f.voteCountGte != null) p['vote_count.gte'] = String(f.voteCountGte) + if (f.runtimeGte != null) p['with_runtime.gte'] = String(f.runtimeGte) + if (f.runtimeLte != null) p['with_runtime.lte'] = String(f.runtimeLte) + if (f.language) p.with_original_language = f.language + if (f.watchProviders.length > 0) { + p.with_watch_providers = f.watchProviders.join('|') + if (f.watchRegion) p.watch_region = f.watchRegion + } + if (f.status && f.kind === 'tv') p.with_status = f.status + return p +} diff --git a/src/components/discover/LibraryGapFinder.tsx b/src/components/discover/LibraryGapFinder.tsx new file mode 100644 index 0000000..ac79bc4 --- /dev/null +++ b/src/components/discover/LibraryGapFinder.tsx @@ -0,0 +1,198 @@ +import { useMemo } from 'react' +import { useLibraryGenreDistribution, useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { useTmdbDiscoverMovies, useTmdbTopRatedMovies } from '../../hooks/use-tmdb' +import { usePreferencesStore } from '../../stores/preferences-store' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { tmdbMovieGenreId } from '../../lib/tmdb-genres' +import { filterToMissing } from '../../pages/discover/helpers' +import ContentRow from '../ui/ContentRow' + +/** + * Set of "interesting" genres we consider when looking for library + * gaps. Limited to the ones that have a clear TMDB equivalent and are + * reasonable to recommend in - "Music" or "TV Movie" are excluded + * because suggestions there are usually noise. + */ +const INTERESTING_GENRES = [ + 'Documentary', + 'Animation', + 'Horror', + 'Romance', + 'Thriller', + 'Science Fiction', + 'Mystery', + 'Fantasy', + 'Drama', + 'Comedy', + 'Action', + 'Adventure', + 'Crime', + 'Family', + 'War', + 'Western', + 'History', +] + +/** + * "Your library is heavy on X, light on Y" finder. Computes the genre + * distribution across the user's Movie + Series catalogue, picks the + * underrepresented INTERESTING_GENRES (definition: < 30% of the + * top-genre count AND fewer than 12 absolute items), and surfaces a + * top-rated TMDB row for each one. + * + * Hides itself when: + * - The user has fewer than 30 items total (results would be noise) + * - No genre crosses the under-representation threshold + */ +export default function LibraryGapFinder() { + const distQuery = useLibraryGenreDistribution() + const lib = useLibraryByTmdbId() + + const gaps = useMemo(() => { + const data = distQuery.data + if (!data || data.total < 30) return [] as Array<{ genre: string; count: number; top: number }> + const top = Math.max(...Array.from(data.counts.values()), 1) + return INTERESTING_GENRES + .map(g => { + const count = data.counts.get(g) || 0 + return { genre: g, count, top } + }) + .filter(g => g.count < 12 && g.count < top * 0.3) + // Most-glaring gaps first (the ones with the biggest delta from top). + .sort((a, b) => a.count - b.count) + .slice(0, 3) + }, [distQuery.data]) + + if (gaps.length === 0) return null + + const data = distQuery.data! + const topEntry = [...data.counts.entries()].sort((a, b) => b[1] - a[1])[0] + const topGenre = topEntry ? topEntry[0] : null + + return ( +
    +
    +
    + + + Library gaps + +
    +

    + What your shelves are missing +

    +

    + You have plenty of {topGenre || '...'}{' '} + but barely anything in {gaps.map((g, i) => ( + + {i > 0 && (i === gaps.length - 1 ? ' or ' : ', ')} + {g.genre.toLowerCase()} + ({g.count}) + + ))}. A few top-rated picks worth adding: +

    +
    + {gaps.map(g => ( + + ))} + + + +
    + ) +} + +const A24_COMPANY_ID = '41077' +const OSCAR_KEYWORD_ID = '10271' // Academy Awards + +function CuratedGapRow({ + title, + subtitle, + params, + libraryMap, +}: { + title: string + subtitle: string + params: Record + libraryMap?: Map +}) { + const movies = useTmdbDiscoverMovies(params) + const hideAdult = usePreferencesStore(s => s.hideAdult) + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap) + }, [movies.data, libraryMap, hideAdult]) + if (items.length === 0) return null + return ( + + ) +} + +function GapRow({ + genre, + libraryMap, +}: { + genre: string + libraryMap?: Map +}) { + const genreId = tmdbMovieGenreId(genre) + const params = genreId + ? { + with_genres: String(genreId), + 'vote_count.gte': '1000', + 'vote_average.gte': '7.2', + sort_by: 'vote_average.desc', + } + : ({} as Record) + const movies = useTmdbDiscoverMovies(params) + const hideAdult = usePreferencesStore(s => s.hideAdult) + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap) + }, [movies.data, libraryMap, hideAdult]) + if (items.length === 0) return null + return ( + + ) +} diff --git a/src/components/discover/MoodChips.tsx b/src/components/discover/MoodChips.tsx new file mode 100644 index 0000000..2d813a8 --- /dev/null +++ b/src/components/discover/MoodChips.tsx @@ -0,0 +1,145 @@ +import { useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { usePreferencesStore } from '../../stores/preferences-store' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { DISCOVER_MOODS, type DiscoverMood } from '../../lib/discover-moods' +import { filterToMissing } from '../../pages/discover/helpers' +import ContentRow from '../ui/ContentRow' + +interface Props { + activeId: string | null + onChange: (id: string | null) => void + kind: 'movie' | 'tv' +} + +export function MoodChips({ activeId, onChange, kind }: Props) { + return ( +
    +
    + + + In the mood for + +
    +
    +
      + {DISCOVER_MOODS.map(mood => { + const available = kind === 'movie' ? !!mood.movieParams : !!mood.tvParams + return ( + available && onChange(activeId === mood.id ? null : mood.id)} + /> + ) + })} +
    +
    +
    + ) +} + +function MoodChipButton({ + mood, + active, + disabled, + onClick, +}: { + mood: DiscoverMood + active: boolean + disabled: boolean + onClick: () => void +}) { + const Icon = mood.icon + return ( +
  • + + + {mood.label} + +
  • + ) +} + +/** + * The row that materialises beneath the chip strip when a mood is picked. + * Movie-only moods show just one row; the few that have TV recipes show + * two rows (movies then shows). + */ +export function MoodRow({ moodId, kind }: { moodId: string; kind: 'movie' | 'tv' }) { + const mood = DISCOVER_MOODS.find(m => m.id === moodId) + if (!mood) return null + return ( + + + {kind === 'movie' && } + {kind === 'tv' && mood.tvParams && } + + + ) +} + +function MoodMovieRow({ mood }: { mood: DiscoverMood }) { + const movies = useTmdbDiscoverMovies(mood.movieParams) + const lib = useLibraryByTmdbId() + const hideAdult = usePreferencesStore(s => s.hideAdult) + const items = useMemo(() => { + let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + if (mood.extra) raw = raw.filter(mood.extra) + return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data) + }, [movies.data, lib.data, hideAdult, mood]) + if (items.length === 0) return null + return ( + + ) +} + +function MoodTvRow({ mood }: { mood: DiscoverMood }) { + const tv = useTmdbDiscoverTv(mood.tvParams || {}) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (tv.data?.results || []).map(m => ({ ...m, media_type: 'tv' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [tv.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} diff --git a/src/components/discover/OnThisDay.tsx b/src/components/discover/OnThisDay.tsx new file mode 100644 index 0000000..3b8fc05 --- /dev/null +++ b/src/components/discover/OnThisDay.tsx @@ -0,0 +1,146 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { motion } from 'framer-motion' +import { useNavigate } from 'react-router-dom' +import { CalendarEvent, Star } from '../../lib/icons' +import { discoverMovies, type TmdbMovie } from '../../api/tmdb' + +const TMDB_IMG = 'https://image.tmdb.org/t/p' +const YEARS_BACK = [5, 10, 15, 20, 25, 30, 40, 50] + +/** + * "On this day in cinema" rail. For a handful of anniversary windows + * (5 / 10 / 15... years ago this week), pulls the top-rated film TMDB + * recorded as released near today's date that year, and renders each as + * a horizontal card. Self-hides every year-slot with no qualifying + * release, and hides the whole rail if nothing qualifies anywhere. + * + * Uses a +/- 3-day window rather than exact MM-DD because exact-date + * matches are rare - "this week in cinema history" reads naturally and + * surfaces enough material to be interesting. + */ +export default function OnThisDay() { + const navigate = useNavigate() + const today = useMemo(() => new Date(), []) + + const queries = useMemo(() => { + const y = today.getFullYear() + const m = String(today.getMonth() + 1).padStart(2, '0') + const d = today.getDate() + const fromD = String(Math.max(1, d - 3)).padStart(2, '0') + // Clamp upper bound to 28 to avoid edge cases in shorter months. + const toD = String(Math.min(28, d + 3)).padStart(2, '0') + return YEARS_BACK.map(yearsAgo => { + const year = y - yearsAgo + return { + yearsAgo, + year, + params: { + 'primary_release_date.gte': `${year}-${m}-${fromD}`, + 'primary_release_date.lte': `${year}-${m}-${toD}`, + 'vote_count.gte': '500', + 'vote_average.gte': '7', + sort_by: 'vote_average.desc', + }, + } + }) + }, [today]) + + const results = useQueries({ + queries: queries.map(q => ({ + queryKey: ['otd', q.year, q.params], + queryFn: () => discoverMovies(q.params), + staleTime: 24 * 60 * 60 * 1000, + })), + }) + + const picks = useMemo(() => { + return queries + .map((q, i) => { + const res = results[i]?.data + const top = (res?.results || [])[0] as TmdbMovie | undefined + if (!top || !top.poster_path) return null + return { yearsAgo: q.yearsAgo, year: q.year, movie: top } + }) + .filter((x): x is { yearsAgo: number; year: number; movie: TmdbMovie } => !!x) + }, [queries, results]) + + if (picks.length === 0) return null + + return ( +
    +
    +
    + + + This week in cinema + +
    +

    + What dropped around {formatToday(today)} - across the decades. +

    +
    + +
    +
      + {picks.map((pick, i) => ( + + + + ))} +
    +
    +
    + ) +} + +function formatToday(d: Date): string { + return d.toLocaleDateString(undefined, { month: 'long', day: 'numeric' }) +} + +function formatReleaseDate(iso: string | null | undefined): string { + if (!iso) return '' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} diff --git a/src/components/discover/Roulette.tsx b/src/components/discover/Roulette.tsx new file mode 100644 index 0000000..b7a8f29 --- /dev/null +++ b/src/components/discover/Roulette.tsx @@ -0,0 +1,270 @@ +import { useMemo, useState, useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useNavigate } from 'react-router-dom' +import { Shuffle, RotateCw, ArrowRight, Star, X } from '../../lib/icons' +import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { usePreferencesStore } from '../../stores/preferences-store' +import { DISCOVER_MOODS } from '../../lib/discover-moods' +import { filterToMissing } from '../../pages/discover/helpers' + +const TMDB_IMG = 'https://image.tmdb.org/t/p' + +interface Props { + kind: 'movie' | 'tv' + /** Optional mood id - when set, the roulette picks from that mood's pool. */ + moodId?: string | null +} + +/** + * "Pick for me" roulette. Pulls a pool of titles matching the current + * Discover context (mood when active, otherwise top-rated by popularity), + * filters out items the user already owns, and surfaces a single random + * pick in a centered modal. Cheap dopamine hit + cure for choice paralysis. + */ +export default function Roulette({ kind, moodId }: Props) { + const [open, setOpen] = useState(false) + + return ( + <> + + + + {open && ( + setOpen(false)} /> + )} + + + ) +} + +function RouletteModal({ + kind, + moodId, + onClose, +}: { + kind: 'movie' | 'tv' + moodId?: string | null + onClose: () => void +}) { + const navigate = useNavigate() + const hideAdult = usePreferencesStore(s => s.hideAdult) + const lib = useLibraryByTmdbId() + + // Resolve the source query based on context. Mood wins when present; + // otherwise fall back to popularity-sorted, well-voted picks. + const mood = moodId ? DISCOVER_MOODS.find(m => m.id === moodId) : null + const params = + mood && (kind === 'movie' ? mood.movieParams : mood.tvParams) + || { + sort_by: 'popularity.desc', + 'vote_count.gte': '1000', + 'vote_average.gte': '6.5', + } + + const movies = useTmdbDiscoverMovies(kind === 'movie' ? params : {}) + const tv = useTmdbDiscoverTv(kind === 'tv' ? params : {}) + const data = kind === 'movie' ? movies.data : tv.data + const isLoading = kind === 'movie' ? movies.isLoading : tv.isLoading + + const pool = useMemo(() => { + const raw = (data?.results || []).map(m => ({ ...m, media_type: kind })) + let filtered = filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult) + if (mood?.extra) filtered = filtered.filter(mood.extra as any) + return filtered.filter((m: any) => m.poster_path && m.overview) + }, [data, lib.data, hideAdult, kind, mood]) + + const [pickIndex, setPickIndex] = useState(() => Math.floor(Math.random() * 20)) + const [spinNonce, setSpinNonce] = useState(0) + + // Whenever the pool changes (mood swap / data loads), reseed the pick. + useEffect(() => { + if (pool.length > 0) { + setPickIndex(Math.floor(Math.random() * pool.length)) + } + }, [pool.length, moodId, kind]) + + const pick = pool[pickIndex % Math.max(1, pool.length)] || null + + function spin() { + if (pool.length < 2) return + let next = pickIndex + // Avoid landing on the same pick consecutively. + while (next === pickIndex) next = Math.floor(Math.random() * pool.length) + setPickIndex(next) + setSpinNonce(n => n + 1) + } + + function open() { + if (!pick) return + const mediaType = (pick as any).media_type === 'tv' || (pick as any).first_air_date ? 'tv' : 'movie' + navigate(`/item/tmdb-${mediaType}-${(pick as any).id}`) + onClose() + } + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + if (e.key === ' ') { + e.preventDefault() + spin() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pickIndex, pool.length]) + + const p = pick as any + const title = p?.title || p?.name || '' + const year = (p?.release_date || p?.first_air_date || '').slice(0, 4) + const rating = typeof p?.vote_average === 'number' ? p.vote_average.toFixed(1) : null + const overview: string = p?.overview || '' + const backdrop = p?.backdrop_path ? `${TMDB_IMG}/w1280${p.backdrop_path}` : null + const poster = p?.poster_path ? `${TMDB_IMG}/w500${p.poster_path}` : null + + return ( + + e.stopPropagation()} + className="relative w-[760px] max-w-[92vw] rounded-2xl overflow-hidden bg-[#0c0a08]/97 ring-1 ring-white/14 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.85)]" + > + + + {/* Backdrop band */} +
    + + + {backdrop && ( + + )} +
    + + +
    + + + Pick for you + + {mood && ( + + - {mood.label} + + )} +
    +
    + + {/* Body */} +
    + + + {poster ? ( + {title} + ) : ( +
    + ? +
    + )} +
    +
    + +
    + + +

    + {title || (isLoading ? 'Spinning...' : 'No matches')} +

    +
    + {year && {year}} + {year && rating && ·} + {rating && ( + + + {rating} + + )} + · + + {kind === 'tv' ? 'Series' : 'Movie'} + +
    +

    + {overview || (isLoading ? 'Pulling a pick...' : 'Try a different mood - this pool came back empty.')} +

    +
    +
    +
    +
    + + {/* Actions */} +
    + + + + {pool.length > 0 ? `${pool.length} in pool` : ''} + +
    +
    + + ) +} diff --git a/src/components/discover/SpotlightHero.tsx b/src/components/discover/SpotlightHero.tsx new file mode 100644 index 0000000..0fa3921 --- /dev/null +++ b/src/components/discover/SpotlightHero.tsx @@ -0,0 +1,109 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { ArrowRight, Star } from '../../lib/icons' +import { useTmdbTrending } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { usePreferencesStore } from '../../stores/preferences-store' +import { filterToMissing } from '../../pages/discover/helpers' + +const TMDB_IMG = 'https://image.tmdb.org/t/p' + +/** + * Editorial pick at the top of Discover. Picks the highest-ranked + * trending-today item the user doesn't already own, with a backdrop + + * synopsis available. If nothing qualifies, the hero hides itself so + * the page falls back to the chips + rows beneath. + * + * Visual: 21:9 backdrop, left-side gradient ramp to bg-void so the + * copy stays legible against any image. The DNA borrows the hero pattern + * from DetailPage's hero but in a smaller, contained card. + */ +export function SpotlightHero({ kind = 'all' }: { kind?: 'all' | 'movie' | 'tv' }) { + const navigate = useNavigate() + const trending = useTmdbTrending(kind, 'day') + const lib = useLibraryByTmdbId() + const hideAdult = usePreferencesStore(s => s.hideAdult) + + const pick = useMemo(() => { + const list = trending.data?.results || [] + const filtered = filterToMissing(list, lib.data, hideAdult, m => !!(m as any).adult) + return filtered.find(m => (m as any).backdrop_path && (m as any).overview) || null + }, [trending.data, lib.data, hideAdult]) + + if (!pick) return null + + const p = pick as any + const title: string = p.title || p.name || 'Untitled' + const overview: string = p.overview || '' + const backdrop = `${TMDB_IMG}/w1280${p.backdrop_path}` + const year = (p.release_date || p.first_air_date || '').slice(0, 4) + const rating = typeof p.vote_average === 'number' ? p.vote_average.toFixed(1) : null + const mediaType: 'movie' | 'tv' = p.media_type === 'tv' || p.first_air_date ? 'tv' : 'movie' + + function open() { + navigate(`/item/tmdb-${mediaType}-${p.id}`) + } + + return ( + + +
    +
    +
    + + + + ) +} diff --git a/src/components/discover/TonightHero.tsx b/src/components/discover/TonightHero.tsx new file mode 100644 index 0000000..a18bb45 --- /dev/null +++ b/src/components/discover/TonightHero.tsx @@ -0,0 +1,164 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { ArrowRight, Star, MoonStars } from '../../lib/icons' +import { useTmdbDiscoverMovies, useTmdbDiscoverTv, useTmdbTrending } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId, useLibraryGenreDistribution } from '../../hooks/use-jellyfin' +import { usePreferencesStore } from '../../stores/preferences-store' +import { filterToMissing } from '../../pages/discover/helpers' +import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres' + +const TMDB_IMG = 'https://image.tmdb.org/t/p' + +interface Props { + kind: 'movie' | 'tv' +} + +/** + * Personalized "tonight" pick. Reads the user's library genre + * distribution, finds their top genre, and picks one highly-rated + * unowned title from that genre as a featured spotlight. + * + * Falls back to TMDB trending-day for cold-start users (small or no + * library), so the component always renders something useful. + * + * The card mirrors the older SpotlightHero visually but reframes the + * copy: this isn't "what's hot on TMDB", it's "we read what you + * actually watch". + */ +export default function TonightHero({ kind }: Props) { + const navigate = useNavigate() + const hideAdult = usePreferencesStore(s => s.hideAdult) + const lib = useLibraryByTmdbId() + const dist = useLibraryGenreDistribution() + + // Top genre across the user's library, if we have enough material to + // call it a real signal. Below 30 items we treat the user as + // cold-start and fall back to a non-personalized pick. + const topGenre = useMemo(() => { + const data = dist.data + if (!data || data.total < 30) return null + const sorted = [...data.counts.entries()].sort((a, b) => b[1] - a[1]) + return sorted[0]?.[0] || null + }, [dist.data]) + + const genreId = topGenre + ? kind === 'movie' + ? tmdbMovieGenreId(topGenre) + : tmdbTvGenreId(topGenre) + : null + + const personalizedParams = genreId + ? { + with_genres: String(genreId), + 'vote_count.gte': '2000', + 'vote_average.gte': '7.5', + sort_by: 'popularity.desc', + } + : ({} as Record) + + const personalMovies = useTmdbDiscoverMovies(kind === 'movie' && genreId ? personalizedParams : {}) + const personalTv = useTmdbDiscoverTv(kind === 'tv' && genreId ? personalizedParams : {}) + const personalData = kind === 'movie' ? personalMovies.data : personalTv.data + + // Cold-start fallback: trending-day, same as the old Spotlight. + const trending = useTmdbTrending(kind, 'day') + + const pick = useMemo(() => { + // Personalized pool wins when present + non-empty. + const personalRaw = (personalData?.results || []).map(m => ({ ...m, media_type: kind })) + const personalPool = filterToMissing(personalRaw, lib.data, hideAdult, m => !!(m as any).adult) + .filter((m: any) => m.backdrop_path && m.overview) + if (personalPool.length > 0) { + return { item: personalPool[0], reason: 'personalized' as const, genre: topGenre } + } + // Fallback: trending-day filtered to unowned. + const trendRaw = trending.data?.results || [] + const trendPool = filterToMissing(trendRaw, lib.data, hideAdult, m => !!(m as any).adult) + .filter((m: any) => m.backdrop_path && m.overview) + if (trendPool.length > 0) { + return { item: trendPool[0], reason: 'trending' as const, genre: null } + } + return null + }, [personalData, trending.data, lib.data, hideAdult, kind, topGenre]) + + if (!pick) return null + + const p = pick.item as any + const title: string = p.title || p.name || '' + const overview: string = p.overview || '' + const backdrop = `${TMDB_IMG}/w1280${p.backdrop_path}` + const year = (p.release_date || p.first_air_date || '').slice(0, 4) + const rating = typeof p.vote_average === 'number' ? p.vote_average.toFixed(1) : null + const mediaType: 'movie' | 'tv' = (p.media_type === 'tv' || p.first_air_date) ? 'tv' : 'movie' + + function open() { + navigate(`/item/tmdb-${mediaType}-${p.id}`) + } + + const eyebrowText = pick.reason === 'personalized' + ? `Tonight - because you watch a lot of ${pick.genre?.toLowerCase()}` + : 'Tonight - trending picks' + + return ( + + +
    +
    +
    + + + + ) +}