diff --git a/src/pages/discover/chrome.tsx b/src/pages/discover/chrome.tsx new file mode 100644 index 0000000..aaf0eee --- /dev/null +++ b/src/pages/discover/chrome.tsx @@ -0,0 +1,58 @@ +import { Compass } from '../../lib/icons' + +export function NoKey() { + return ( +
+
+
+
+ +
+
+

+ Discover needs a TMDB key +

+

+ This page surfaces films and shows you don't already own. It pulls trending, top-rated, upcoming, by-genre, by-language, by-studio, and by-network rows from TMDB - all of which require an API key to load. +

+ + Open TMDB settings + +
+ ) +} + +export function Hero() { + return ( +
+
+ + + Discover + +
+

+ Find your next favorite +

+

+ Films and shows you don't have yet, sourced from TMDB. +

+
+ ) +} + +export function SectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) { + return ( +
+
+ + {eyebrow} +
+

{title}

+

{subtitle}

+
+ ) +} diff --git a/src/pages/discover/filtered-grid.tsx b/src/pages/discover/filtered-grid.tsx new file mode 100644 index 0000000..7377ddd --- /dev/null +++ b/src/pages/discover/filtered-grid.tsx @@ -0,0 +1,91 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import PosterCard from '../../components/ui/PosterCard' +import { usePosterGridClasses } from '../../lib/density' +import { filtersToTmdbParams, type DiscoverFilterState } from '../../components/discover/DiscoverFilters' +import type { BaseItemDto } from '../../api/types' + +/** + * Shown when any filter is active or sort has been changed from the default. + * Renders a single grid of TMDB results constrained by the filter selections, + * with optional library-hide. + */ +export function FilteredGrid({ filters }: { filters: DiscoverFilterState }) { + const navigate = useNavigate() + const lib = useLibraryByTmdbId() + const grid = usePosterGridClasses() + const params = useMemo(() => filtersToTmdbParams(filters), [filters]) + const moviesQ = useTmdbDiscoverMovies(filters.kind === 'movie' ? params : ({} as Record)) + const tvQ = useTmdbDiscoverTv(filters.kind === 'tv' ? params : ({} as Record)) + const data = filters.kind === 'movie' ? moviesQ.data : tvQ.data + const isLoading = filters.kind === 'movie' ? moviesQ.isLoading : tvQ.isLoading + + const items = useMemo(() => { + const raw = (data?.results || []).map(m => ({ + ...m, + media_type: filters.kind === 'movie' ? 'movie' : 'tv', + })) + let list = raw + if (filters.hideOwned && lib.data) { + list = list.filter(m => !lib.data!.has(String(m.id))) + } + return mapTmdbToJf(list, lib.data) as BaseItemDto[] + }, [data, filters.kind, filters.hideOwned, lib.data]) + + return ( +
+
+

+ {isLoading ? ( + 'Searching...' + ) : ( + <> + {items.length} + matching {filters.kind === 'movie' ? 'movies' : 'shows'} + {filters.hideOwned && ( + (excluding your library) + )} + + )} +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
+

+ No matches +

+

+ Loosen the filters to see more results. Reduce the minimum rating, expand the year range, or clear genre selections. +

+
+ ) : ( +
+ {items.map((item, i) => ( + item.Id && navigate(`/item/${item.Id}`)} + /> + ))} +
+ )} +
+ ) +} diff --git a/src/pages/discover/helpers.ts b/src/pages/discover/helpers.ts new file mode 100644 index 0000000..38ec140 --- /dev/null +++ b/src/pages/discover/helpers.ts @@ -0,0 +1,41 @@ +/** + * Filter raw TMDB results down to ones the user does NOT own. The library + * map keys items by their TMDB id; we drop anything that hits. When no + * library map has loaded yet, returns an empty array so rows hide rather + * than briefly showing items the user already owns. + */ +export function filterToMissing( + raw: T[], + lib?: Map | null, + hideAdult = false, + adultFlag?: (m: T) => boolean, +): T[] { + if (!lib) return [] + let list = raw.filter(m => !lib.has(String(m.id))) + if (hideAdult && adultFlag) list = list.filter(m => !adultFlag(m)) + return list +} + +export const GENRE_ROWS: Array<{ label: string; subtitle: string }> = [ + { label: 'Action', subtitle: 'Bullets, fists, explosions' }, + { label: 'Drama', subtitle: 'Stories that hit deep' }, + { label: 'Comedy', subtitle: 'Light watching' }, + { label: 'Science Fiction', subtitle: 'Beyond the possible' }, + { label: 'Thriller', subtitle: 'Edge-of-your-seat tension' }, + { label: 'Horror', subtitle: 'Lights off' }, + { label: 'Animation', subtitle: 'Drawn to perfection' }, + { label: 'Romance', subtitle: 'Love and longing' }, + { label: 'Fantasy', subtitle: 'Worlds of magic' }, + { label: 'Mystery', subtitle: 'Puzzles to solve' }, +] + +export const LANGUAGE_ROWS: Array<{ code: string; label: string; subtitle: string }> = [ + { code: 'ja', label: 'Japanese cinema', subtitle: 'From Kurosawa to Kore-eda' }, + { code: 'ko', label: 'Korean cinema', subtitle: 'New wave thrillers and drama' }, + { code: 'fr', label: 'French cinema', subtitle: 'Auteurs and arthouse' }, + { code: 'es', label: 'Spanish-language cinema', subtitle: 'Spain and Latin America' }, + { code: 'de', label: 'German cinema', subtitle: 'Berlin school and beyond' }, + { code: 'it', label: 'Italian cinema', subtitle: 'Neo-realism to giallo' }, + { code: 'zh', label: 'Chinese-language cinema', subtitle: 'Mainland, Hong Kong, Taiwan' }, + { code: 'hi', label: 'Hindi cinema', subtitle: 'Bollywood at its best' }, +] diff --git a/src/pages/discover/rows.tsx b/src/pages/discover/rows.tsx new file mode 100644 index 0000000..20fd3b3 --- /dev/null +++ b/src/pages/discover/rows.tsx @@ -0,0 +1,337 @@ +import { useMemo } from 'react' +import { + useTmdbTrending, + useTmdbDiscoverMovies, + useTmdbDiscoverTv, + useTmdbUpcoming, + useTmdbTopRatedMovies, + useTmdbTopRatedTv, +} from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres' +import ContentRow from '../../components/ui/ContentRow' +import { usePreferencesStore } from '../../stores/preferences-store' +import { filterToMissing } from './helpers' + +export function TrendingDayRow() { + const trending = useTmdbTrending('all', 'day') + const lib = useLibraryByTmdbId() + const hideAdult = usePreferencesStore(s => s.hideAdult) + const items = useMemo(() => { + const raw = (trending.data?.results || []) + const filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult) + return mapTmdbToJf(filtered, lib.data) + }, [trending.data, lib.data, hideAdult]) + if (items.length === 0) return null + return ( + + ) +} + +export function TrendingWeekRow() { + const trending = useTmdbTrending('all', 'week') + const lib = useLibraryByTmdbId() + const hideAdult = usePreferencesStore(s => s.hideAdult) + const items = useMemo(() => { + const raw = (trending.data?.results || []) + const filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult) + return mapTmdbToJf(filtered, lib.data) + }, [trending.data, lib.data, hideAdult]) + if (items.length === 0) return null + return ( + + ) +} + +export function PopularMoviesRow() { + const movies = useTmdbDiscoverMovies({ sort_by: 'popularity.desc' }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function PopularTvRow() { + const tv = useTmdbDiscoverTv({ sort_by: 'popularity.desc' }) + 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 ( + + ) +} + +export function UpcomingMoviesRow({ region }: { region: string }) { + const upcoming = useTmdbUpcoming(region) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (upcoming.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [upcoming.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function TopRatedMoviesRow() { + const movies = useTmdbTopRatedMovies() + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function TopRatedTvRow() { + const tv = useTmdbTopRatedTv() + 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 ( + + ) +} + +export function CultClassicsRow() { + const movies = useTmdbDiscoverMovies({ + 'vote_count.gte': '10000', + 'vote_average.gte': '8', + 'popularity.lte': '20', + sort_by: 'vote_average.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function HighlyRatedThisYearRow() { + const year = new Date().getFullYear() + const movies = useTmdbDiscoverMovies({ + primary_release_year: String(year), + 'vote_count.gte': '200', + sort_by: 'vote_average.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function ForeignFilmsRow() { + const movies = useTmdbDiscoverMovies({ + 'vote_count.gte': '500', + sort_by: 'vote_average.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []) + .filter(m => m.original_language && m.original_language !== 'en') + .slice(0, 20) + .map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function DocumentariesRow() { + const movies = useTmdbDiscoverMovies({ + with_genres: '99', + 'vote_count.gte': '300', + sort_by: 'vote_average.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function GenreRow({ genre }: { genre: { label: string; subtitle: string } }) { + const movieGenreId = tmdbMovieGenreId(genre.label) + const tvGenreId = tmdbTvGenreId(genre.label) + const movies = useTmdbDiscoverMovies( + movieGenreId + ? { with_genres: String(movieGenreId), 'vote_count.gte': '500', sort_by: 'vote_average.desc' } + : ({} as Record), + ) + const tv = useTmdbDiscoverTv( + tvGenreId + ? { with_genres: String(tvGenreId), 'vote_count.gte': '200', sort_by: 'vote_average.desc' } + : ({} as Record), + ) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const m = (movies.data?.results || []).map(x => ({ ...x, media_type: 'movie' })) + const t = (tv.data?.results || []).map(x => ({ ...x, media_type: 'tv' })) + const out: { id: number; media_type: string }[] = [] + const max = Math.max(m.length, t.length) + for (let i = 0; i < max && out.length < 20; i++) { + if (m[i]) out.push(m[i]) + if (t[i]) out.push(t[i]) + } + return mapTmdbToJf(filterToMissing(out, lib.data), lib.data) + }, [movies.data, tv.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function LanguageRow({ lang }: { lang: { code: string; label: string; subtitle: string } }) { + const movies = useTmdbDiscoverMovies({ + with_original_language: lang.code, + 'vote_count.gte': '200', + sort_by: 'vote_average.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function StudioRow({ brandId, label, subtitle }: { brandId: number; label: string; subtitle: string }) { + const movies = useTmdbDiscoverMovies({ + with_companies: String(brandId), + sort_by: 'popularity.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (movies.data?.results || []).slice(0, 18).map(m => ({ ...m, media_type: 'movie' })) + return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data) + }, [movies.data, lib.data]) + if (items.length === 0) return null + return ( + + ) +} + +export function NetworkRow({ brandId, label, subtitle }: { brandId: number; label: string; subtitle: string }) { + const tv = useTmdbDiscoverTv({ + with_networks: String(brandId), + sort_by: 'popularity.desc', + }) + const lib = useLibraryByTmdbId() + const items = useMemo(() => { + const raw = (tv.data?.results || []).slice(0, 18).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 ( + + ) +}