type discover components

This commit is contained in:
2026-04-26 07:48:34 +03:00
parent c746ce1b7c
commit 9641dfc1a9
8 changed files with 52 additions and 53 deletions
+2 -3
View File
@@ -27,9 +27,8 @@ function isSameSelection(a: BrowseKey | null, b: BrowseKey | null): boolean {
if (a.kind !== b.kind) return false if (a.kind !== b.kind) return false
if (a.kind === 'genre' && b.kind === 'genre') return a.label === b.label 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 === 'language' && b.kind === 'language') return a.code === b.code
if ((a.kind === 'studio' && b.kind === 'studio') || (a.kind === 'network' && b.kind === 'network')) { if (a.kind === 'studio' && b.kind === 'studio') return a.id === b.id
return (a as any).id === (b as any).id if (a.kind === 'network' && b.kind === 'network') return a.id === b.id
}
return false return false
} }
+5 -4
View File
@@ -5,6 +5,7 @@ import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { usePreferencesStore } from '../../stores/preferences-store' import { usePreferencesStore } from '../../stores/preferences-store'
import { mapTmdbToJf } from '../../lib/tmdb-mapping' import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import { filterToMissing } from '../../pages/discover/helpers' import { filterToMissing } from '../../pages/discover/helpers'
import type { TmdbMovie } from '../../api/tmdb'
interface CanonicalList { interface CanonicalList {
id: string id: string
@@ -12,7 +13,7 @@ interface CanonicalList {
subtitle: string subtitle: string
params: Record<string, string> params: Record<string, string>
/** Optional client-side filter applied on top of TMDB results. */ /** Optional client-side filter applied on top of TMDB results. */
extra?: (m: { original_language?: string; vote_count?: number }) => boolean extra?: (m: TmdbMovie) => boolean
} }
/** /**
@@ -123,9 +124,9 @@ function CanonicalRow({ list }: { list: CanonicalList }) {
const lib = useLibraryByTmdbId() const lib = useLibraryByTmdbId()
const hideAdult = usePreferencesStore(s => s.hideAdult) const hideAdult = usePreferencesStore(s => s.hideAdult)
const items = useMemo(() => { const items = useMemo(() => {
let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' as const }))
if (list.extra) raw = raw.filter(list.extra as any) if (list.extra) raw = raw.filter(list.extra)
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data) return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!m.adult), lib.data)
}, [movies.data, lib.data, hideAdult, list]) }, [movies.data, lib.data, hideAdult, list])
if (items.length === 0) return null if (items.length === 0) return null
return ( return (
+2 -2
View File
@@ -140,8 +140,8 @@ function DecadeRow({ decade, kind }: { decade: Decade; kind: 'movie' | 'tv' }) {
const data = kind === 'movie' ? movieQuery.data : tvQuery.data const data = kind === 'movie' ? movieQuery.data : tvQuery.data
const items = useMemo(() => { const items = useMemo(() => {
const raw = (data?.results || []).map(m => ({ ...m, media_type: kind })) const raw = (data?.results || []).map(m => ({ ...m, media_type: kind as const }))
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data) return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!m.adult), lib.data)
}, [data, lib.data, hideAdult, kind]) }, [data, lib.data, hideAdult, kind])
if (items.length === 0) return null if (items.length === 0) return null
+4 -4
View File
@@ -150,8 +150,8 @@ function CuratedGapRow({
const movies = useTmdbDiscoverMovies(params) const movies = useTmdbDiscoverMovies(params)
const hideAdult = usePreferencesStore(s => s.hideAdult) const hideAdult = usePreferencesStore(s => s.hideAdult)
const items = useMemo(() => { const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' as const }))
return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap) return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!m.adult), libraryMap)
}, [movies.data, libraryMap, hideAdult]) }, [movies.data, libraryMap, hideAdult])
if (items.length === 0) return null if (items.length === 0) return null
return ( return (
@@ -183,8 +183,8 @@ function GapRow({
const movies = useTmdbDiscoverMovies(params) const movies = useTmdbDiscoverMovies(params)
const hideAdult = usePreferencesStore(s => s.hideAdult) const hideAdult = usePreferencesStore(s => s.hideAdult)
const items = useMemo(() => { const items = useMemo(() => {
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' as const }))
return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap) return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!m.adult), libraryMap)
}, [movies.data, libraryMap, hideAdult]) }, [movies.data, libraryMap, hideAdult])
if (items.length === 0) return null if (items.length === 0) return null
return ( return (
+2 -2
View File
@@ -112,9 +112,9 @@ function MoodMovieRow({ mood }: { mood: DiscoverMood }) {
const lib = useLibraryByTmdbId() const lib = useLibraryByTmdbId()
const hideAdult = usePreferencesStore(s => s.hideAdult) const hideAdult = usePreferencesStore(s => s.hideAdult)
const items = useMemo(() => { const items = useMemo(() => {
let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' as const }))
if (mood.extra) raw = raw.filter(mood.extra) if (mood.extra) raw = raw.filter(mood.extra)
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult), lib.data) return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!m.adult), lib.data)
}, [movies.data, lib.data, hideAdult, mood]) }, [movies.data, lib.data, hideAdult, mood])
if (items.length === 0) return null if (items.length === 0) return null
return ( return (
+13 -13
View File
@@ -7,6 +7,7 @@ import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { usePreferencesStore } from '../../stores/preferences-store' import { usePreferencesStore } from '../../stores/preferences-store'
import { DISCOVER_MOODS } from '../../lib/discover-moods' import { DISCOVER_MOODS } from '../../lib/discover-moods'
import { filterToMissing } from '../../pages/discover/helpers' import { filterToMissing } from '../../pages/discover/helpers'
import type { TmdbDiscoverItem } from '../../api/tmdb'
const TMDB_IMG = 'https://image.tmdb.org/t/p' const TMDB_IMG = 'https://image.tmdb.org/t/p'
@@ -76,10 +77,10 @@ function RouletteModal({
const isLoading = kind === 'movie' ? movies.isLoading : tv.isLoading const isLoading = kind === 'movie' ? movies.isLoading : tv.isLoading
const pool = useMemo(() => { const pool = useMemo(() => {
const raw = (data?.results || []).map(m => ({ ...m, media_type: kind })) const raw = (data?.results || []).map(m => ({ ...m, media_type: kind })) as TmdbDiscoverItem[]
let filtered = filterToMissing(raw, lib.data, hideAdult, m => !!(m as any).adult) let filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult)
if (mood?.extra) filtered = filtered.filter(mood.extra as any) if (mood?.extra) filtered = filtered.filter(mood.extra as (m: TmdbDiscoverItem) => boolean)
return filtered.filter((m: any) => m.poster_path && m.overview) return filtered.filter(m => m.poster_path && m.overview)
}, [data, lib.data, hideAdult, kind, mood]) }, [data, lib.data, hideAdult, kind, mood])
const [pickIndex, setPickIndex] = useState(() => Math.floor(Math.random() * 20)) const [pickIndex, setPickIndex] = useState(() => Math.floor(Math.random() * 20))
@@ -105,8 +106,8 @@ function RouletteModal({
function open() { function open() {
if (!pick) return if (!pick) return
const mediaType = (pick as any).media_type === 'tv' || (pick as any).first_air_date ? 'tv' : 'movie' const mediaType = pick.media_type === 'tv' || pick.first_air_date ? 'tv' : 'movie'
navigate(`/item/tmdb-${mediaType}-${(pick as any).id}`) navigate(`/item/tmdb-${mediaType}-${pick.id}`)
onClose() onClose()
} }
@@ -123,13 +124,12 @@ function RouletteModal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [pickIndex, pool.length]) }, [pickIndex, pool.length])
const p = pick as any const title = pick?.title || pick?.name || ''
const title = p?.title || p?.name || '' const year = (pick?.release_date || pick?.first_air_date || '').slice(0, 4)
const year = (p?.release_date || p?.first_air_date || '').slice(0, 4) const rating = typeof pick?.vote_average === 'number' ? pick.vote_average.toFixed(1) : null
const rating = typeof p?.vote_average === 'number' ? p.vote_average.toFixed(1) : null const overview: string = pick?.overview || ''
const overview: string = p?.overview || '' const backdrop = pick?.backdrop_path ? `${TMDB_IMG}/w1280${pick.backdrop_path}` : null
const backdrop = p?.backdrop_path ? `${TMDB_IMG}/w1280${p.backdrop_path}` : null const poster = pick?.poster_path ? `${TMDB_IMG}/w500${pick.poster_path}` : null
const poster = p?.poster_path ? `${TMDB_IMG}/w500${p.poster_path}` : null
return ( return (
<motion.div <motion.div
+10 -11
View File
@@ -27,22 +27,21 @@ export function SpotlightHero({ kind = 'all' }: { kind?: 'all' | 'movie' | 'tv'
const pick = useMemo(() => { const pick = useMemo(() => {
const list = trending.data?.results || [] const list = trending.data?.results || []
const filtered = filterToMissing(list, lib.data, hideAdult, m => !!(m as any).adult) const filtered = filterToMissing(list, lib.data, hideAdult, m => !!m.adult)
return filtered.find(m => (m as any).backdrop_path && (m as any).overview) || null return filtered.find(m => m.backdrop_path && m.overview) || null
}, [trending.data, lib.data, hideAdult]) }, [trending.data, lib.data, hideAdult])
if (!pick) return null if (!pick) return null
const p = pick as any const title: string = pick.title || pick.name || 'Untitled'
const title: string = p.title || p.name || 'Untitled' const overview: string = pick.overview || ''
const overview: string = p.overview || '' const backdrop = `${TMDB_IMG}/w1280${pick.backdrop_path}`
const backdrop = `${TMDB_IMG}/w1280${p.backdrop_path}` const year = (pick.release_date || pick.first_air_date || '').slice(0, 4)
const year = (p.release_date || p.first_air_date || '').slice(0, 4) const rating = typeof pick.vote_average === 'number' ? pick.vote_average.toFixed(1) : null
const rating = typeof p.vote_average === 'number' ? p.vote_average.toFixed(1) : null const mediaType: 'movie' | 'tv' = pick.media_type === 'tv' || pick.first_air_date ? 'tv' : 'movie'
const mediaType: 'movie' | 'tv' = p.media_type === 'tv' || p.first_air_date ? 'tv' : 'movie'
function open() { function open() {
navigate(`/item/tmdb-${mediaType}-${p.id}`) navigate(`/item/tmdb-${mediaType}-${pick.id}`)
} }
return ( return (
@@ -54,7 +53,7 @@ export function SpotlightHero({ kind = 'all' }: { kind?: 'all' | 'movie' | 'tv'
style={{ aspectRatio: '21/9', maxHeight: '440px' }} style={{ aspectRatio: '21/9', maxHeight: '440px' }}
> >
<motion.img <motion.img
key={p.id} key={pick.id}
initial={{ scale: 1.04, opacity: 0 }} initial={{ scale: 1.04, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
+14 -14
View File
@@ -66,16 +66,16 @@ export default function TonightHero({ kind }: Props) {
const pick = useMemo(() => { const pick = useMemo(() => {
// Personalized pool wins when present + non-empty. // Personalized pool wins when present + non-empty.
const personalRaw = (personalData?.results || []).map(m => ({ ...m, media_type: kind })) const personalRaw = (personalData?.results || []).map(m => ({ ...m, media_type: kind as const }))
const personalPool = filterToMissing(personalRaw, lib.data, hideAdult, m => !!(m as any).adult) const personalPool = filterToMissing(personalRaw, lib.data, hideAdult, m => !!m.adult)
.filter((m: any) => m.backdrop_path && m.overview) .filter(m => m.backdrop_path && m.overview)
if (personalPool.length > 0) { if (personalPool.length > 0) {
return { item: personalPool[0], reason: 'personalized' as const, genre: topGenre } return { item: personalPool[0], reason: 'personalized' as const, genre: topGenre }
} }
// Fallback: trending-day filtered to unowned. // Fallback: trending-day filtered to unowned.
const trendRaw = trending.data?.results || [] const trendRaw = trending.data?.results || []
const trendPool = filterToMissing(trendRaw, lib.data, hideAdult, m => !!(m as any).adult) const trendPool = filterToMissing(trendRaw, lib.data, hideAdult, m => !!m.adult)
.filter((m: any) => m.backdrop_path && m.overview) .filter(m => m.backdrop_path && m.overview)
if (trendPool.length > 0) { if (trendPool.length > 0) {
return { item: trendPool[0], reason: 'trending' as const, genre: null } return { item: trendPool[0], reason: 'trending' as const, genre: null }
} }
@@ -84,16 +84,16 @@ export default function TonightHero({ kind }: Props) {
if (!pick) return null if (!pick) return null
const p = pick.item as any const item = pick.item
const title: string = p.title || p.name || '' const title: string = item.title || item.name || ''
const overview: string = p.overview || '' const overview: string = item.overview || ''
const backdrop = `${TMDB_IMG}/w1280${p.backdrop_path}` const backdrop = `${TMDB_IMG}/w1280${item.backdrop_path}`
const year = (p.release_date || p.first_air_date || '').slice(0, 4) const year = (item.release_date || item.first_air_date || '').slice(0, 4)
const rating = typeof p.vote_average === 'number' ? p.vote_average.toFixed(1) : null const rating = typeof item.vote_average === 'number' ? item.vote_average.toFixed(1) : null
const mediaType: 'movie' | 'tv' = (p.media_type === 'tv' || p.first_air_date) ? 'tv' : 'movie' const mediaType: 'movie' | 'tv' = (item.media_type === 'tv' || item.first_air_date) ? 'tv' : 'movie'
function open() { function open() {
navigate(`/item/tmdb-${mediaType}-${p.id}`) navigate(`/item/tmdb-${mediaType}-${item.id}`)
} }
const eyebrowText = pick.reason === 'personalized' const eyebrowText = pick.reason === 'personalized'
@@ -108,7 +108,7 @@ export default function TonightHero({ kind }: Props) {
className="mx-7 mb-9 relative rounded-2xl overflow-hidden ring-1 ring-border bg-elevated/40 shadow-[0_24px_48px_-16px_rgba(0,0,0,0.65)] h-[200px] sm:h-[240px] md:h-[300px] lg:h-[380px] xl:h-[440px]" className="mx-7 mb-9 relative rounded-2xl overflow-hidden ring-1 ring-border bg-elevated/40 shadow-[0_24px_48px_-16px_rgba(0,0,0,0.65)] h-[200px] sm:h-[240px] md:h-[300px] lg:h-[380px] xl:h-[440px]"
> >
<motion.img <motion.img
key={p.id} key={item.id}
initial={{ scale: 1.04, opacity: 0 }} initial={{ scale: 1.04, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}