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 ( <> 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" > Pick for me {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 || (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 */} View details Spin again {pool.length > 0 ? `${pool.length} in pool` : ''} ) }
{overview || (isLoading ? 'Pulling a pick...' : 'Try a different mood - this pool came back empty.')}