import { useEffect, useMemo, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useNavigate } from 'react-router-dom' import { X, Check, Play } from '../../lib/icons' import { useEpisodes, useItemDetails, useSeasons } from '../../hooks/use-jellyfin' import { useCinemeta } from '../../hooks/use-external' import { getBestImage } from '../../api/jellyfin' import { formatRuntime } from '../../lib/format' import { buildCinemetaEpisodeMap } from '../../api/cinemeta' import { useAnimeFiller, classifyEpisode } from '../../lib/anime-filler' import { EpisodeMetaProvider } from '../../lib/episode-meta-context' import EpisodeRatingChip from '../detail/EpisodeRatingChip' import FillerChip from '../detail/FillerChip' import { usePreferencesStore } from '../../stores/preferences-store' import Select, { type SelectOption } from '../ui/Select' /** * Netflix-style in-player episode browser. Slides in from the right when the * user clicks the episodes button in the player chrome. Shows the current * season's episodes with thumbnails, runtimes, watch progress, and a season * dropdown at the top. Clicking an episode navigates the player to that * episode's id. * * Mounted at the player's root level so it can sit above the click-capture * layer and the controls bars without fighting their pointer events. */ interface Props { open: boolean onClose: () => void seriesId: string currentItemId: string /** Falls back to the season currently in the URL if the current item lives elsewhere. */ initialSeasonId?: string serverUrl: string } export default function EpisodesPanel({ open, onClose, seriesId, currentItemId, initialSeasonId, serverUrl, }: Props) { const navigate = useNavigate() const panelRef = useRef(null) const { data: seasons = [] } = useSeasons(seriesId) const [seasonId, setSeasonId] = useState(initialSeasonId) // Pick a sensible default season the first time data lands. useEffect(() => { if (seasonId) return if (initialSeasonId) { setSeasonId(initialSeasonId) return } if (seasons.length > 0) setSeasonId(seasons[0].Id || undefined) }, [seasons, initialSeasonId, seasonId]) const { data: episodes = [] } = useEpisodes(seriesId, seasonId) // Pull series IMDB / TMDB ids so we can attach rating + filler chips to // the episode rows the same way the detail page does. const { data: seriesItem } = useItemDetails(seriesId) const seriesImdbId = seriesItem?.ProviderIds?.Imdb || null const seriesTmdbId = seriesItem?.ProviderIds?.Tmdb ? Number(seriesItem.ProviderIds.Tmdb) : null const { data: cinemetaSeriesData } = useCinemeta(seriesImdbId, 'series') const cinemetaMap = useMemo( () => buildCinemetaEpisodeMap(cinemetaSeriesData), [cinemetaSeriesData], ) const fillerData = useAnimeFiller(seriesTmdbId) const seasonStarts = useMemo(() => { const m = new Map() if (!seasons) return m let running = 0 const ordered = [...seasons] .filter(s => (s.IndexNumber ?? 0) > 0) .sort((a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)) for (const s of ordered) { const idx = s.IndexNumber ?? 0 m.set(idx, running) running += s.ChildCount ?? 0 } return m }, [seasons]) const fillerOf = useMemo(() => { return (season: number | null | undefined, episode: number | null | undefined) => { if (!fillerData || season == null || episode == null) return null const start = seasonStarts.get(season) if (start == null) return null return classifyEpisode(start + episode, fillerData) } }, [fillerData, seasonStarts]) const spoilerBlur = usePreferencesStore(s => s.episode.show.spoilerBlur) // Specials always last to match the rest of the app. const orderedEpisodes = useMemo(() => { return [...episodes].sort((a: any, b: any) => { const aSpecial = a.ParentIndexNumber === 0 const bSpecial = b.ParentIndexNumber === 0 if (aSpecial && !bSpecial) return 1 if (!aSpecial && bSpecial) return -1 return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0) }) }, [episodes]) // Close on Escape so the user can dismiss without reaching for the mouse. useEffect(() => { if (!open) return function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { e.stopPropagation() onClose() } } window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) }, [open, onClose]) const seasonOptions: SelectOption[] = seasons .filter(s => !!s.Id) .map(s => ({ value: s.Id as string, label: s.IndexNumber === 0 ? 'Specials' : s.Name || `Season ${s.IndexNumber ?? '?'}`, })) return ( {open && ( <> {/* Backdrop - dims the player area, dismisses on click */} {/* Panel */} {/* Header */}

Episodes

{seasonOptions.length > 0 && (