discover helpers
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import { Compass } from '../../lib/icons'
|
||||||
|
|
||||||
|
export function NoKey() {
|
||||||
|
return (
|
||||||
|
<div className="px-7 py-20 max-w-xl mx-auto text-center">
|
||||||
|
<div className="relative w-14 h-14 mx-auto mb-5">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||||
|
<Compass size={20} className="text-text-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-[22px] font-bold text-text-1 tracking-tight mb-2">
|
||||||
|
Discover needs a TMDB key
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-text-3 leading-relaxed mb-5">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/settings#tmdb"
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-full bg-accent text-void font-semibold text-[12.5px] hover:bg-accent-hover transition focus-ring"
|
||||||
|
>
|
||||||
|
Open TMDB settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-7 pb-5">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3 rounded-full bg-accent" />
|
||||||
|
<span className="text-[10px] font-semibold text-text-3 uppercase tracking-[0.2em]">
|
||||||
|
Discover
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-[26px] md:text-[30px] font-bold tracking-tight text-text-1 leading-[1.1] mb-1">
|
||||||
|
Find your next favorite
|
||||||
|
</h1>
|
||||||
|
<p className="text-[12.5px] text-text-3 max-w-xl">
|
||||||
|
Films and shows you don't have yet, sourced from TMDB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) {
|
||||||
|
return (
|
||||||
|
<div className="px-7 mb-5 pt-6">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, string>))
|
||||||
|
const tvQ = useTmdbDiscoverTv(filters.kind === 'tv' ? params : ({} as Record<string, string>))
|
||||||
|
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 (
|
||||||
|
<div className="px-7 pt-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[12px] text-text-3 tracking-tight">
|
||||||
|
{isLoading ? (
|
||||||
|
'Searching...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="tabular-nums text-text-1 font-semibold">{items.length}</span>
|
||||||
|
<span className="text-text-3"> matching {filters.kind === 'movie' ? 'movies' : 'shows'}</span>
|
||||||
|
{filters.hideOwned && (
|
||||||
|
<span className="text-text-4"> (excluding your library)</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={grid}>
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||||
|
<div className="mt-2.5 space-y-1.5">
|
||||||
|
<div className="skeleton h-3 w-3/4 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-20 text-center max-w-md mx-auto">
|
||||||
|
<p className="font-display text-[18px] text-text-1 font-semibold tracking-tight mb-2">
|
||||||
|
No matches
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-text-3 leading-relaxed">
|
||||||
|
Loosen the filters to see more results. Reduce the minimum rating, expand the year range, or clear genre selections.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={grid}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<PosterCard
|
||||||
|
key={item.Id || i}
|
||||||
|
item={item}
|
||||||
|
priority={i < 6}
|
||||||
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T extends { id: number; original_language?: string }>(
|
||||||
|
raw: T[],
|
||||||
|
lib?: Map<string, unknown> | 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' },
|
||||||
|
]
|
||||||
@@ -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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Trending today"
|
||||||
|
subtitle="Buzz on TMDB right now - things you don't have"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_trending_day"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Trending this week"
|
||||||
|
subtitle="The week's biggest titles you're missing"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_trending_week"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Popular movies"
|
||||||
|
subtitle="Top of TMDB right now - missing from your library"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_popular_movies"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Popular shows"
|
||||||
|
subtitle="Top of TMDB right now - missing from your library"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_popular_tv"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Coming soon"
|
||||||
|
subtitle={`New theatrical and streaming releases${region ? ` in ${region}` : ''}`}
|
||||||
|
items={items}
|
||||||
|
layoutKey={`discover_upcoming_${region}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Critically acclaimed movies"
|
||||||
|
subtitle="The highest-rated films on TMDB you don't yet own"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_top_rated_movies"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Critically acclaimed shows"
|
||||||
|
subtitle="The highest-rated series on TMDB you don't yet own"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_top_rated_tv"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Cult classics"
|
||||||
|
subtitle="Highly rated films flying under the radar"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_cult"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title={`Highly rated this year (${year})`}
|
||||||
|
subtitle="The best-rated theatrical and streaming releases of the year"
|
||||||
|
items={items}
|
||||||
|
layoutKey={`discover_year_${year}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Foreign cinema"
|
||||||
|
subtitle="Top-rated non-English films you don't own"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_foreign"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title="Documentaries"
|
||||||
|
subtitle="The best-rated docs on TMDB you don't have"
|
||||||
|
items={items}
|
||||||
|
layoutKey="discover_docs"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>),
|
||||||
|
)
|
||||||
|
const tv = useTmdbDiscoverTv(
|
||||||
|
tvGenreId
|
||||||
|
? { with_genres: String(tvGenreId), 'vote_count.gte': '200', sort_by: 'vote_average.desc' }
|
||||||
|
: ({} as Record<string, string>),
|
||||||
|
)
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title={genre.label}
|
||||||
|
subtitle={genre.subtitle}
|
||||||
|
items={items}
|
||||||
|
layoutKey={`discover_genre_${genre.label}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title={lang.label}
|
||||||
|
subtitle={lang.subtitle}
|
||||||
|
items={items}
|
||||||
|
layoutKey={`discover_lang_${lang.code}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title={label}
|
||||||
|
subtitle={subtitle}
|
||||||
|
items={items}
|
||||||
|
layoutKey={`discover_studio_${brandId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ContentRow
|
||||||
|
title={label}
|
||||||
|
subtitle={subtitle}
|
||||||
|
items={items}
|
||||||
|
layoutKey={`discover_network_${brandId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user