Files
jellybloom/src/components/player/EpisodesPanel.tsx
T
2026-03-29 06:40:45 +03:00

314 lines
13 KiB
TypeScript

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<HTMLElement>(null)
const { data: seasons = [] } = useSeasons(seriesId)
const [seasonId, setSeasonId] = useState<string | undefined>(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<number, number>()
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<string>[] = seasons
.filter(s => !!s.Id)
.map(s => ({
value: s.Id as string,
label: s.IndexNumber === 0 ? 'Specials' : s.Name || `Season ${s.IndexNumber ?? '?'}`,
}))
return (
<EpisodeMetaProvider cinemetaMap={cinemetaMap} fillerOf={fillerOf}>
<AnimatePresence>
{open && (
<>
{/* Backdrop - dims the player area, dismisses on click */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
/>
{/* Panel */}
<motion.aside
ref={panelRef as any}
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
data-episodes-panel
role="dialog"
aria-label="Episodes"
>
{/* Header */}
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
Episodes
</h2>
<button
onClick={onClose}
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{seasonOptions.length > 0 && (
<Select
value={seasonId || ''}
onChange={v => setSeasonId(v)}
options={seasonOptions}
width="w-full"
ariaLabel="Season"
portalContainer={panelRef.current}
/>
)}
</header>
{/* Episode list */}
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-2">
{orderedEpisodes.length === 0 && (
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
No episodes found in this season.
</p>
)}
{orderedEpisodes.map(ep => {
const epId = ep.Id || ''
const isCurrent = epId === currentItemId
const watched = ep.UserData?.Played === true
const progressTicks = Number(ep.UserData?.PlaybackPositionTicks ?? 0)
const runtimeTicks = Number(ep.RunTimeTicks ?? 0)
const progressPct =
progressTicks > 0 && runtimeTicks > 0
? Math.min(100, (progressTicks / runtimeTicks) * 100)
: 0
const epNum = ep.IndexNumber
const seasonNum = ep.ParentIndexNumber
const thumb = getBestImage(serverUrl, ep, 'thumb', 360)
return (
<button
key={epId}
onClick={() => {
if (epId && !isCurrent) {
onClose()
navigate(`/play/${epId}`)
}
}}
disabled={isCurrent}
className={`w-full text-left rounded-lg overflow-hidden border transition-colors duration-150 group ${
isCurrent
? 'border-accent/70 bg-accent/8 cursor-default'
: 'border-white/10 hover:border-white/25 hover:bg-white/5 focus-ring'
}`}
>
<div className="flex gap-3 p-2">
<div className="relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-black">
{thumb ? (
<img
src={thumb}
alt=""
className={`w-full h-full object-cover transition-[filter,transform] duration-300 ${
isCurrent ? '' : 'group-hover:scale-[1.04]'
} ${
spoilerBlur && !watched && !isCurrent
? 'blur-[10px] saturate-[0.6] group-hover:blur-0 group-hover:saturate-100'
: ''
}`}
/>
) : (
<div className="w-full h-full bg-elevated" />
)}
{/* Play overlay on hover (skipped for current episode) */}
{!isCurrent && (
<div className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="w-9 h-9 rounded-full bg-white/95 grid place-items-center text-void">
<Play size={14} fill="currentColor" className="translate-x-px" />
</span>
</div>
)}
{watched && !isCurrent && (
<span className="absolute top-1 right-1 w-5 h-5 rounded-full bg-accent text-void grid place-items-center">
<Check size={11} strokeWidth={3} />
</span>
)}
<span className="absolute top-1 left-1 flex gap-1">
<FillerChip season={seasonNum} episode={epNum} />
</span>
{progressPct > 0 && progressPct < 95 && !watched && (
<div className="absolute left-0 right-0 bottom-0 h-1 bg-black/45">
<div
className="h-full bg-accent"
style={{ width: `${progressPct}%` }}
/>
</div>
)}
{isCurrent && (
<div className="absolute inset-0 grid place-items-center bg-black/45">
<span className="text-[10px] uppercase tracking-[0.16em] font-semibold text-accent">
Now playing
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 py-0.5">
<p
className={`text-[10.5px] uppercase tracking-[0.14em] font-semibold mb-0.5 ${
isCurrent ? 'text-accent' : 'text-white/55'
}`}
>
{seasonNum != null && epNum != null
? `S${seasonNum} · E${epNum}`
: `Episode ${epNum ?? '?'}`}
{runtimeTicks > 0 && (
<span className="text-white/40 normal-case tracking-normal font-normal ml-1.5">
· {formatRuntime(runtimeTicks)}
</span>
)}
<span className="ml-1.5 inline-flex">
<EpisodeRatingChip season={seasonNum} episode={epNum} />
</span>
</p>
<p
className={`text-[13px] font-medium leading-tight tracking-tight line-clamp-2 ${
isCurrent ? 'text-white' : 'text-white/90'
}`}
>
{ep.Name || `Episode ${epNum ?? ''}`}
</p>
{ep.Overview && (
<p
className={`text-[11.5px] text-white/55 leading-snug mt-1 line-clamp-2 transition-[filter,color] duration-200 ${
spoilerBlur && !watched && !isCurrent
? 'blur-[5px] text-white/35 hover:blur-0 hover:text-white/55'
: ''
}`}
>
{ep.Overview}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
</motion.aside>
</>
)}
</AnimatePresence>
</EpisodeMetaProvider>
)
}