314 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|