271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
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 (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
title="Pick something for me"
|
|
aria-label="Pick something for me"
|
|
className="inline-flex items-center gap-2 h-10 pl-3.5 pr-4 rounded-full bg-cool/15 hover:bg-cool/22 text-cool border border-cool/30 hover:border-cool/45 text-[12.5px] font-semibold tracking-tight transition-all duration-200 hover:scale-[1.03] active:scale-[0.97] focus-ring"
|
|
>
|
|
<Shuffle size={14} stroke={2.25} />
|
|
Pick for me
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{open && (
|
|
<RouletteModal kind={kind} moodId={moodId} onClose={() => setOpen(false)} />
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.18 }}
|
|
className="fixed inset-0 z-toast grid place-items-center bg-black/70 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.92, y: 16 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 8 }}
|
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
|
onClick={e => 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)]"
|
|
>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="absolute top-3 right-3 z-20 w-9 h-9 grid place-items-center rounded-full text-white/75 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
|
|
{/* Backdrop band */}
|
|
<div className="relative h-[260px] overflow-hidden">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={`bg-${spinNonce}`}
|
|
initial={{ opacity: 0, scale: 1.08 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute inset-0"
|
|
>
|
|
{backdrop && (
|
|
<img src={backdrop} alt="" className="w-full h-full object-cover" />
|
|
)}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0c0a08] via-[#0c0a08]/55 to-[#0c0a08]/20" />
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
<div className="absolute top-5 left-5 flex items-center gap-2 z-10">
|
|
<span className="w-1 h-3 rounded-full bg-cool" />
|
|
<span className="text-[10.5px] uppercase tracking-[0.2em] font-semibold text-cool">
|
|
Pick for you
|
|
</span>
|
|
{mood && (
|
|
<span className="text-[10.5px] text-white/55 tracking-tight">
|
|
- {mood.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="-mt-24 relative px-7 pb-7 flex gap-5">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={`poster-${spinNonce}`}
|
|
initial={{ opacity: 0, y: 16, rotateY: 28 }}
|
|
animate={{ opacity: 1, y: 0, rotateY: 0 }}
|
|
exit={{ opacity: 0, y: -8, rotateY: -16 }}
|
|
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
|
className="shrink-0 w-[140px] aspect-[2/3] rounded-xl overflow-hidden ring-1 ring-white/12 shadow-[0_18px_36px_-12px_rgba(0,0,0,0.8)] bg-elevated"
|
|
>
|
|
{poster ? (
|
|
<img src={poster} alt={title} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full grid place-items-center text-white/40 text-3xl font-display">
|
|
?
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
<div className="flex-1 min-w-0 pt-24">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={`info-${spinNonce}`}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<h2 className="font-display text-[22px] md:text-[26px] font-bold text-white tracking-tight leading-[1.1] mb-1.5">
|
|
{title || (isLoading ? 'Spinning...' : 'No matches')}
|
|
</h2>
|
|
<div className="flex items-center gap-2 text-[11.5px] text-white/65 mb-3 tabular-nums">
|
|
{year && <span>{year}</span>}
|
|
{year && rating && <span className="text-white/30">·</span>}
|
|
{rating && (
|
|
<span className="inline-flex items-center gap-1 text-accent">
|
|
<Star size={11} fill="currentColor" stroke={0} />
|
|
{rating}
|
|
</span>
|
|
)}
|
|
<span className="text-white/30">·</span>
|
|
<span className="uppercase tracking-[0.14em] text-[10px] font-semibold text-white/55">
|
|
{kind === 'tv' ? 'Series' : 'Movie'}
|
|
</span>
|
|
</div>
|
|
<p className="text-[12.5px] text-white/80 leading-relaxed line-clamp-4">
|
|
{overview || (isLoading ? 'Pulling a pick...' : 'Try a different mood - this pool came back empty.')}
|
|
</p>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="px-7 pb-6 flex items-center gap-2.5">
|
|
<button
|
|
onClick={open}
|
|
disabled={!pick}
|
|
className="inline-flex items-center gap-2 h-10 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight disabled:opacity-40 disabled:cursor-not-allowed transition-transform duration-200 hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
|
|
>
|
|
View details
|
|
<ArrowRight size={13} stroke={2.25} />
|
|
</button>
|
|
<button
|
|
onClick={spin}
|
|
disabled={pool.length < 2}
|
|
className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-white/8 hover:bg-white/14 text-white/90 border border-white/15 hover:border-white/25 text-[12.5px] font-medium tracking-tight disabled:opacity-40 disabled:cursor-not-allowed transition-colors focus-ring"
|
|
title="Spin again (Space)"
|
|
>
|
|
<RotateCw size={13} stroke={2.25} />
|
|
Spin again
|
|
</button>
|
|
<span className="ml-auto text-[10.5px] text-white/40 tabular-nums">
|
|
{pool.length > 0 ? `${pool.length} in pool` : ''}
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)
|
|
}
|