detail page components

This commit is contained in:
2026-03-27 23:06:44 +02:00
parent 02f0f58ec9
commit a039249ede
41 changed files with 6470 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import type { WikidataAward } from '../../api/wikidata'
interface Props {
awards: WikidataAward[] | null | undefined
}
const PRIORITY_KEYWORDS = [
'Academy Award',
'Oscar',
'Golden Globe',
'BAFTA',
'Emmy',
'Cannes',
'Palme',
'Berlin',
'Sundance',
'Venice',
'Critics',
'SAG',
'Hugo',
'Nebula',
'Saturn',
'MTV',
]
/**
* Compact award grid pulled from Wikidata P166. We surface the most
* recognised prizes first (Oscars, Globes, BAFTAs, Emmys, etc.), then
* dedupe and cap at a sensible count. A "Show all" toggle reveals the
* remainder when there are many.
*/
export default function AwardsBlock({ awards }: Props) {
const [expanded, setExpanded] = useState(false)
const ordered = useMemo(() => {
if (!awards) return []
const seen = new Set<string>()
const unique: WikidataAward[] = []
for (const a of awards) {
const key = a.label
if (seen.has(key)) continue
seen.add(key)
unique.push(a)
}
return unique.sort((a, b) => priority(a) - priority(b))
}, [awards])
if (ordered.length === 0) return null
const visible = expanded ? ordered : ordered.slice(0, 12)
return (
<div>
<div className="flex flex-wrap gap-1.5">
{visible.map((a, i) => (
<motion.div
key={a.id || a.label}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: Math.min(i * 0.02, 0.3) }}
title={[a.ceremony, a.point_in_time?.slice(0, 4), a.for_work].filter(Boolean).join(' · ')}
className="inline-flex items-center gap-1.5 h-7 px-3 bg-elevated/60 ring-1 ring-border hover:ring-border-strong rounded-full text-[11.5px] text-text-2 tracking-tight"
>
<svg className="w-3 h-3 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a1 1 0 011 1v.5a4.5 4.5 0 014.5 4.5v.5h.5a1.5 1.5 0 010 3H15v.5a5 5 0 01-4 4.9V18h2a1 1 0 110 2H7a1 1 0 110-2h2v-1.1A5 5 0 015 12v-.5h-.5a1.5 1.5 0 010-3H5V8a4.5 4.5 0 014.5-4.5V3a1 1 0 011-1z" />
</svg>
<span className="font-medium">{a.label}</span>
{a.point_in_time && (
<span className="text-text-4 tabular-nums">
{a.point_in_time.slice(0, 4)}
</span>
)}
</motion.div>
))}
</div>
{ordered.length > 12 && (
<button
onClick={() => setExpanded(v => !v)}
className="mt-3 text-[11.5px] text-text-3 hover:text-accent transition tracking-tight focus-ring rounded"
>
{expanded ? 'Show fewer' : `Show all ${ordered.length}`}
</button>
)}
</div>
)
}
function priority(a: WikidataAward): number {
const label = a.label || ''
for (let i = 0; i < PRIORITY_KEYWORDS.length; i++) {
if (label.includes(PRIORITY_KEYWORDS[i])) return i
}
return PRIORITY_KEYWORDS.length
}
+63
View File
@@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import HorizontalScroller from '../ui/HorizontalScroller'
import { getTmdbImageUrl } from '../../api/tmdb'
interface FallbackPerson {
Id?: string | null
Name?: string | null
Role?: string | null
PrimaryImageTag?: string | null
Type?: string | null
}
interface Props {
cast: any[]
fallbackPeople?: FallbackPerson[] | null
}
export default function CastList({ cast, fallbackPeople }: Props) {
const navigate = useNavigate()
const list = cast.length ? cast : (fallbackPeople || []).filter((p: any) => p.Type === 'Actor')
return (
<div className="-mx-7">
<HorizontalScroller gap="gap-2" arrowsBottomInset="bottom-8">
{list.map((c: any, i: number) => (
<motion.button
key={`${c.id ?? c.Id ?? c.name ?? c.Name ?? i}`}
onClick={() => c.id && navigate(`/person/${c.id}`)}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.025, 0.4) }}
whileHover={{ y: -2 }}
className="flex flex-col items-center text-center shrink-0 w-[88px] group focus-ring rounded-lg p-1"
>
<div className="w-16 h-16 rounded-full overflow-hidden bg-elevated mb-2 ring-1 ring-border group-hover:ring-accent/30 transition-all duration-200">
{c.profile_path ? (
<img
src={getTmdbImageUrl(c.profile_path, 'w185')}
alt={c.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-[15px] font-display font-semibold bg-gradient-to-br from-elevated to-higher">
{(c.name || c.Name || '?')[0]}
</div>
)}
</div>
<span className="text-[11px] text-text-1 truncate w-full leading-tight font-medium">
{c.name || c.Name}
</span>
{(c.character || c.Role) && (
<span className="text-[10px] text-text-4 truncate w-full leading-tight mt-0.5">
{c.character || c.Role}
</span>
)}
</motion.button>
))}
</HorizontalScroller>
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { MonitorPlay, Tv, Loader2 } from '../../lib/icons'
import { listCastTargets, sendToSession, type CastTarget } from '../../lib/cast'
import { toast } from '../../stores/toast-store'
interface Props {
itemId: string
mediaType?: string
}
/**
* "Play on..." picker. Pops a small list of other Jellyfin sessions that
* advertise SupportsRemoteControl, filtered by what they can play. Click
* a target to fire `Sessions/{id}/Playing` and hand the item off.
*/
export default function CastMenu({ itemId, mediaType }: Props) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [targets, setTargets] = useState<CastTarget[]>([])
const [sendingTo, setSendingTo] = useState<string | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!open) return
function onDocClick(e: MouseEvent) {
if (!wrapRef.current) return
if (e.target instanceof Node && !wrapRef.current.contains(e.target)) {
setOpen(false)
}
}
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onEsc)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onEsc)
}
}, [open])
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
listCastTargets(mediaType)
.then(t => {
if (!cancelled) setTargets(t)
})
.catch(() => {
if (!cancelled) setTargets([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, mediaType])
async function castTo(target: CastTarget) {
setSendingTo(target.sessionId)
try {
await sendToSession(target.sessionId, itemId)
toast(`Sent to ${target.deviceName}`, 'success')
setOpen(false)
} catch {
toast('Could not send to that device', 'error')
} finally {
setSendingTo(null)
}
}
return (
<div ref={wrapRef} className="relative">
<button
onClick={() => setOpen(o => !o)}
aria-label="Play on another device"
title="Play on another device"
aria-expanded={open}
className={`w-11 h-11 rounded-lg backdrop-blur grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 focus-ring ${
open
? 'bg-accent/15 border border-accent/30'
: 'bg-white/10 border border-white/15 hover:bg-white/15'
}`}
>
<MonitorPlay size={16} className="text-white/80" />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.97 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
className="absolute bottom-full mb-2 right-0 w-[280px] z-30 bg-[#0c0a08]/96 backdrop-blur-xl border border-white/14 rounded-xl shadow-[0_20px_50px_-15px_rgba(0,0,0,0.7)] overflow-hidden"
role="menu"
>
<div className="px-3.5 pt-3 pb-2 border-b border-white/8">
<p className="text-[10px] uppercase tracking-[0.16em] font-semibold text-white/55">
Play on
</p>
</div>
<div className="max-h-[280px] overflow-y-auto content-scroll py-1.5">
{loading && (
<div className="px-3.5 py-4 flex items-center gap-2 text-[12px] text-white/60">
<Loader2 size={13} className="animate-spin" />
Looking for devices...
</div>
)}
{!loading && targets.length === 0 && (
<p className="px-3.5 py-4 text-[12px] text-white/55 leading-snug">
No other devices are signed in to this server right now.
</p>
)}
{!loading &&
targets.map(t => {
const isSending = sendingTo === t.sessionId
return (
<button
key={t.sessionId}
onClick={() => castTo(t)}
disabled={!!sendingTo}
className="w-full flex items-center gap-3 px-3.5 py-2.5 text-left hover:bg-white/6 transition-colors disabled:opacity-50 focus-ring"
>
<span className="w-8 h-8 rounded-md bg-white/8 grid place-items-center shrink-0">
{isSending ? (
<Loader2 size={13} className="text-white/70 animate-spin" />
) : (
<Tv size={14} className="text-white/70" />
)}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[12.5px] font-medium text-white tracking-tight truncate">
{t.deviceName}
</span>
<span className="block text-[10.5px] text-white/50 truncate">
{[t.client, t.userName].filter(Boolean).join(' - ')}
</span>
</span>
</button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Boxes, ChevronRight, Check } from '../../lib/icons'
import { getTmdbImageUrl, type TmdbCollectionRef, type TmdbCollection } from '../../api/tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
interface Props {
collectionRef?: TmdbCollectionRef | null
collection?: TmdbCollection | null
currentMovieId?: number | null
}
export default function CollectionStrip({ collectionRef, collection, currentMovieId }: Props) {
const navigate = useNavigate()
const { data: libraryMap } = useLibraryByTmdbId()
const ref = collection || collectionRef
if (!ref) return null
// Sort by release date ascending. Items with no release date (or an
// unparseable one) sort to the END so unannounced sequels don't hijack
// the #1 slot just because their date field is empty.
const parts = (collection?.parts || []).slice().sort((a, b) => {
const ta = a.release_date ? Date.parse(a.release_date) : NaN
const tb = b.release_date ? Date.parse(b.release_date) : NaN
const da = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY
const db = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY
return da - db
})
const inLibraryCount = libraryMap
? parts.reduce((acc, p) => acc + (libraryMap.has(String(p.id)) ? 1 : 0), 0)
: 0
return (
<div className="relative overflow-hidden rounded-xl border border-border">
{/* Backdrop */}
{ref.backdrop_path && (
<>
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${getTmdbImageUrl(ref.backdrop_path, 'w1280')})` }}
/>
<div className="absolute inset-0 bg-gradient-to-r from-void/95 via-void/85 to-void/55" />
<div className="absolute inset-0 bg-gradient-to-t from-void/95 to-transparent" />
</>
)}
{!ref.backdrop_path && <div className="absolute inset-0 bg-gradient-to-br from-elevated to-void" />}
<div className="relative p-5">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<button
onClick={() => navigate(`/collection/${ref.id}`)}
className="group flex items-center gap-2 text-left focus-ring rounded-md"
>
<Boxes size={14} className="text-accent" />
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-3">Part of</span>
<span className="text-[15px] font-semibold tracking-tight text-white">{ref.name}</span>
<ChevronRight size={14} className="text-text-3 transition-transform duration-200 group-hover:translate-x-0.5" />
</button>
{parts.length > 0 && libraryMap && (
<span className="text-[11px] text-white/60 tracking-tight">
<span className="tabular-nums text-white font-semibold">{inLibraryCount}</span>
<span className="text-white/45"> of </span>
<span className="tabular-nums text-white">{parts.length}</span>
<span className="text-white/45"> in your library · watch in release order</span>
</span>
)}
</div>
{parts.length > 0 ? (
<div className="flex gap-3 overflow-x-auto hide-scrollbar -mx-1 px-1 py-1">
{parts.map((m, i) => {
const isCurrent = currentMovieId != null && m.id === currentMovieId
const inLibrary = libraryMap?.has(String(m.id)) ?? false
return (
<motion.button
key={m.id}
onClick={() => !isCurrent && navigate(`/item/tmdb-${m.id}`)}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }}
whileHover={{ y: -3 }}
className={`shrink-0 w-[110px] focus-ring rounded-md text-left ${isCurrent ? 'cursor-default' : ''}`}
>
<div className={`relative aspect-[2/3] rounded-md overflow-hidden bg-elevated ring-1 ${
isCurrent ? 'ring-accent' : 'ring-border'
}`}>
{m.poster_path ? (
<img
src={getTmdbImageUrl(m.poster_path, 'w300')}
alt={m.title}
className={`w-full h-full object-cover transition ${
inLibrary || isCurrent ? '' : 'opacity-60 saturate-50'
}`}
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-2xl font-display">
{m.title?.[0]}
</div>
)}
<span
className={`absolute top-1.5 left-1.5 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full text-[10px] font-bold tabular-nums tracking-tight ${
isCurrent
? 'bg-accent text-void'
: inLibrary
? 'bg-white/85 text-void'
: 'bg-black/60 text-white/80 ring-1 ring-white/15'
}`}
>
{i + 1}
</span>
{inLibrary && !isCurrent && (
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-accent/90 text-void grid place-items-center" title="In your library">
<Check size={11} strokeWidth={3} />
</span>
)}
{!inLibrary && !isCurrent && libraryMap && (
<span className="absolute bottom-1.5 left-1.5 right-1.5 text-center text-[9px] uppercase tracking-[0.12em] font-semibold text-white/85 bg-black/55 backdrop-blur-sm rounded px-1 py-0.5">
Missing
</span>
)}
{isCurrent && (
<div className="absolute inset-0 bg-accent/15 grid place-items-end justify-center pb-2">
<span className="px-2 h-5 inline-flex items-center bg-accent text-void rounded-full text-[9px] font-bold uppercase tracking-wider">
Current
</span>
</div>
)}
</div>
<p className="text-[11.5px] text-white/90 truncate mt-1.5 font-medium">{m.title}</p>
{m.release_date && (
<p className="text-[10px] text-white/55 truncate tabular-nums">{m.release_date.slice(0, 4)}</p>
)}
</motion.button>
)
})}
</div>
) : (
<p className="text-[12px] text-text-3 italic">Loading collection...</p>
)}
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useNavigate } from 'react-router-dom'
import type { TmdbCastMember } from '../../api/tmdb'
interface Props {
crew: TmdbCastMember[] | null | undefined
}
const COMPOSER_JOBS = new Set([
'Original Music Composer',
'Music',
'Composer',
'Theme Song Performance',
])
/**
* "Theme by ..." line surfaced from TMDB credits.crew. Surfaces the
* composer(s) without burying them in the dense crew strip. Renders
* nothing when there's no composer credit.
*/
export default function ComposerBlock({ crew }: Props) {
const navigate = useNavigate()
if (!crew || crew.length === 0) return null
const seen = new Set<number>()
const composers: TmdbCastMember[] = []
for (const c of crew) {
if (!c.job || !COMPOSER_JOBS.has(c.job)) continue
if (seen.has(c.id)) continue
seen.add(c.id)
composers.push(c)
if (composers.length >= 3) break
}
if (composers.length === 0) return null
return (
<p className="text-[12.5px] text-text-3 leading-relaxed">
<span className="text-text-4 mr-1.5">Theme by</span>
{composers.map((c, i) => (
<span key={c.id}>
<button
onClick={() => navigate(`/person/${c.id}`)}
className="text-text-1 font-medium hover:text-accent transition focus-ring rounded"
>
{c.name}
</button>
{i < composers.length - 1 && <span className="text-text-5 mx-1.5">·</span>}
</span>
))}
</p>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { getTmdbImageUrl, type TmdbCastMember } from '../../api/tmdb'
import HorizontalScroller from '../ui/HorizontalScroller'
interface Props {
crew: TmdbCastMember[]
limit?: number
}
const DEPT_ORDER = ['Directing', 'Writing', 'Production', 'Camera', 'Editing', 'Sound', 'Visual Effects', 'Art']
export default function CrewGrid({ crew, limit = 14 }: Props) {
if (!crew?.length) return null
// De-duplicate by person id+job and prioritise high-impact departments
const seen = new Set<string>()
const top: TmdbCastMember[] = []
for (const dept of DEPT_ORDER) {
for (const c of crew) {
if (c.department !== dept) continue
const key = `${c.id}-${c.job}`
if (seen.has(key)) continue
seen.add(key)
top.push(c)
if (top.length >= limit) break
}
if (top.length >= limit) break
}
if (!top.length) return null
return (
<div className="-mx-7">
<HorizontalScroller gap="gap-2" arrowsBottomInset="bottom-8">
{top.map((c, i) => (
<CrewMember key={`${c.id}-${c.credit_id ?? i}`} member={c} index={i} />
))}
</HorizontalScroller>
</div>
)
}
function CrewMember({ member, index }: { member: TmdbCastMember; index: number }) {
const navigate = useNavigate()
return (
<motion.button
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(index * 0.025, 0.4) }}
whileHover={{ y: -2 }}
onClick={() => member.id && navigate(`/person/${member.id}`)}
className="flex flex-col items-center text-center shrink-0 w-[88px] group focus-ring rounded-lg p-1"
>
<div className="w-16 h-16 rounded-full overflow-hidden bg-elevated mb-2 ring-1 ring-border group-hover:ring-accent/30 transition-all duration-200">
{member.profile_path ? (
<img
src={getTmdbImageUrl(member.profile_path, 'w185')}
alt={member.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 text-[15px] font-display font-semibold bg-gradient-to-br from-elevated to-higher">
{member.name[0]}
</div>
)}
</div>
<span className="text-[11px] text-text-1 truncate w-full leading-tight font-medium">{member.name}</span>
{member.job && (
<span className="text-[10px] text-text-4 truncate w-full leading-tight mt-0.5">{member.job}</span>
)}
</motion.button>
)
}
+465
View File
@@ -0,0 +1,465 @@
import { motion } from 'framer-motion'
import { useNavigate, type NavigateFunction } from 'react-router-dom'
import {
Play,
Heart,
Clock,
Star,
Plus,
Share2,
ChevronRight,
RefreshCw,
Tv2,
Film as FilmIcon,
Disc3,
} from '../../lib/icons'
import type { BaseItemDto } from '../../api/types'
import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb'
import type { CinemetaMeta } from '../../api/cinemeta'
import MetaBadges from '../ui/MetaBadges'
import WatchlistButton from '../ui/WatchlistButton'
import RequestButton from '../request/RequestButton'
import ExternalLinks from './ExternalLinks'
import HeroTechCard from './HeroTechCard'
import CastMenu from './CastMenu'
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
import { usePreferencesStore } from '../../stores/preferences-store'
import type { useRefreshItem, useSampleEpisode } from '../../hooks/use-jellyfin'
import { buildShareUrl, copyToClipboard } from '../../lib/share'
import { toast } from '../../stores/toast-store'
interface Props {
item: BaseItemDto
itemType: string
tmdbMovieData: TmdbMovie | null | undefined
tmdbTvData: TmdbTvShow | null | undefined
tmdbId: string | null | undefined
posterUrl: string
logoUrl: string | null
backdropUrl: string | null
title: string
year?: number | null
rating: string | null | undefined
runtime: string | null
taglineText: string | null | undefined
genres: string[]
resumeTime: string | null
progress: number | null | undefined
isFavorite: boolean | null | undefined
playUrl: string
resumeUrl: string
trailer: { key: string; official?: boolean } | null | undefined
cinemetaData: CinemetaMeta | null | undefined
showTmdbRatings: boolean
sampleEpisode: ReturnType<typeof useSampleEpisode>
refresh: ReturnType<typeof useRefreshItem>
}
export default function DetailHero({
item,
itemType,
tmdbMovieData,
tmdbTvData,
tmdbId,
posterUrl,
logoUrl,
backdropUrl,
title,
year,
rating,
runtime,
taglineText,
genres,
resumeTime,
progress,
isFavorite,
playUrl,
resumeUrl,
trailer,
cinemetaData,
showTmdbRatings,
sampleEpisode,
refresh,
}: Props) {
const navigate = useNavigate()
const tmdbData = itemType === 'Movie' ? tmdbMovieData : tmdbTvData
const preRollTrailers = usePreferencesStore(s => s.preRollTrailers)
const youtube = useYoutubeViewer()
function handlePlay() {
if (preRollTrailers && trailer && itemType === 'Movie') {
youtube.show({
videoKey: trailer.key,
title: `${title} - Trailer`,
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
onClose: () => navigate(playUrl),
})
return
}
navigate(playUrl)
}
return (
<div className="relative h-[68vh] min-h-[520px] -mt-14 overflow-hidden">
{backdropUrl && (
<motion.div
initial={{ scale: 1.06, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 bg-cover bg-top"
style={{ backgroundImage: `url(${backdropUrl})` }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-void/10" />
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_75%_25%,transparent_0%,rgba(0,0,0,0.5)_85%)]" />
<div className="relative h-full flex items-end pb-12 px-7 gap-7">
{/* Left column: type pill + poster */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
className="hidden md:flex flex-col gap-3 shrink-0"
>
{itemType && <TypePill itemType={itemType} />}
{posterUrl && (
<motion.img
src={posterUrl}
alt={title}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
className="w-[180px] aspect-[2/3] object-cover rounded-xl ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)]"
/>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
className="flex-1 min-w-0 max-w-3xl"
>
{/* Type pill (small screens only - shown above poster on md+) */}
<div className="flex items-center gap-2 mb-4 flex-wrap md:hidden">
{itemType && <TypePill itemType={itemType} />}
<MetaBadges item={item} variant="hero" />
</div>
{itemType === 'Episode' && item.SeriesId && (
<EpisodeBreadcrumb item={item} navigate={navigate} />
)}
{logoUrl ? (
<div className="mb-4 flex justify-start">
<img
src={logoUrl}
alt={title}
className="block h-auto max-h-40 md:max-h-48 max-w-[520px] w-auto drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
) : (
<h1 className="font-display text-5xl md:text-6xl font-bold text-white leading-[0.95] tracking-tight mb-4 text-left drop-shadow-[0_4px_16px_rgba(0,0,0,0.55)]">
{title}
</h1>
)}
{taglineText && (
<p className="text-white/65 text-[15px] italic mb-3 max-w-xl font-display">
"{taglineText}"
</p>
)}
<div className="flex items-center gap-2.5 text-[12px] text-white/70 font-medium mb-3 flex-wrap">
{year && <span className="tabular-nums">{year}</span>}
{year && (rating || runtime) && <Dot />}
{rating && (
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
{rating}
</span>
)}
{rating && runtime && <Dot />}
{runtime && (
<span className="flex items-center gap-1 tabular-nums">
<Clock size={11} /> {runtime}
</span>
)}
{/* Ratings shown on the right-side card (and here only on small screens for accessibility) */}
<span className="lg:hidden flex items-center gap-2.5">
{showTmdbRatings && tmdbData?.vote_average != null && tmdbData.vote_average > 0 && (
<>
<Dot />
<span className="flex items-center gap-1" title="TMDB community score">
<Star size={11} className="text-accent fill-accent" />
<span className="tabular-nums">{tmdbData.vote_average.toFixed(1)}</span>
<span className="text-white/45 text-[10px] uppercase tracking-wide">TMDB</span>
</span>
</>
)}
{item.CommunityRating != null && item.CommunityRating > 0 && (
<>
<Dot />
<span className="flex items-center gap-1 text-cool" title="Jellyfin community average">
<Star size={11} className="fill-current" />
<span className="tabular-nums">{item.CommunityRating.toFixed(1)}</span>
<span className="text-white/45 text-[10px] uppercase tracking-wide">Jellyfin</span>
</span>
</>
)}
</span>
{cinemetaData?.imdbRating && Number(cinemetaData.imdbRating) > 0 && (
<>
<Dot />
<span className="flex items-center gap-1" title="IMDB rating">
<Star size={11} className="text-yellow-400 fill-yellow-400" />
<span className="tabular-nums">{Number(cinemetaData.imdbRating).toFixed(1)}</span>
<span className="text-white/45 text-[10px] uppercase tracking-wide">IMDB</span>
</span>
</>
)}
</div>
{genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{genres.slice(0, 5).map(g => (
<span
key={g}
className="px-2.5 h-6 inline-flex items-center bg-white/8 hover:bg-white/12 backdrop-blur text-white/80 text-[11px] rounded-full border border-white/8 transition-colors cursor-default"
>
{g}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 flex-wrap mb-5">
<button
onClick={handlePlay}
className="group h-11 px-6 bg-white text-void rounded-lg flex items-center gap-2 text-[14px] font-semibold transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-black/30 focus-ring"
>
<Play size={16} strokeWidth={0} fill="currentColor" />
Play
</button>
{resumeTime && (
<button
onClick={() => navigate(resumeUrl)}
className="h-11 px-4 bg-accent/15 text-accent border border-accent/30 hover:bg-accent/20 rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
>
<Play size={14} strokeWidth={0} fill="currentColor" />
Resume from <span className="tabular-nums">{resumeTime}</span>
</button>
)}
{trailer && (
<button
onClick={() =>
useYoutubeViewer.getState().show({
videoKey: trailer.key,
title: `${title} - Trailer`,
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
})
}
className="h-11 px-4 bg-white/10 hover:bg-white/15 text-white border border-white/15 backdrop-blur rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
>
Trailer
</button>
)}
{item.Id && (item.Type === 'Movie' || item.Type === 'Series') && (
<WatchlistButton itemId={item.Id} />
)}
{tmdbId && (item.Type === 'Movie' || item.Type === 'Series') && (
<RequestButton
tmdbId={Number(tmdbId)}
kind={item.Type === 'Movie' ? 'movie' : 'tv'}
tmdbData={tmdbData as any}
/>
)}
<IconButton aria-label={isFavorite ? 'Remove favorite' : 'Add favorite'} active={!!isFavorite}>
<Heart size={16} className={isFavorite ? 'text-accent fill-accent' : 'text-white/80'} />
</IconButton>
<IconButton aria-label="Add to playlist">
<Plus size={16} className="text-white/80" />
</IconButton>
<IconButton
aria-label="Copy share link"
title="Copy share link"
onClick={async () => {
const url = buildShareUrl({
tmdbId,
imdbId: item.ProviderIds?.Imdb,
kind: itemType === 'Series' || itemType === 'Episode' ? 'tv' : 'movie',
})
if (!url) {
toast('No shareable link for this item', 'error')
return
}
const ok = await copyToClipboard(url)
toast(ok ? 'Link copied to clipboard' : 'Could not copy link', ok ? 'success' : 'error')
}}
>
<Share2 size={16} className="text-white/80" />
</IconButton>
{item.Id && <CastMenu itemId={item.Id} mediaType={item.MediaType ?? undefined} />}
<IconButton
aria-label="Refresh metadata"
onClick={() => {
if (!item.Id) return
refresh.mutate({
itemId: item.Id,
replaceAllMetadata: false,
replaceAllImages: false,
})
}}
disabled={refresh.isPending}
title={
refresh.isPending
? 'Refreshing...'
: refresh.isSuccess
? 'Refresh queued'
: 'Re-scrape metadata + images from TMDB / TVDB'
}
>
<RefreshCw
size={16}
className={`text-white/80 ${refresh.isPending ? 'animate-[spin-soft_0.8s_linear_infinite]' : ''}`}
/>
</IconButton>
</div>
<ExternalLinks
tmdbId={tmdbId}
type={itemType === 'Series' ? 'tv' : 'movie'}
ids={tmdbData?.external_ids}
jellyfinExternalUrls={item.ExternalUrls as any}
/>
{progress != null && progress > 0 && (
<div className="mt-5 max-w-md">
<div className="flex items-center justify-between text-[10px] text-white/60 mb-1.5 font-medium uppercase tracking-wider">
<span>Watch progress</span>
<span className="tabular-nums">{Math.round(progress)}%</span>
</div>
<div className="h-1 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
className="h-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]"
/>
</div>
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.35 }}
className="self-end"
>
<HeroTechCard
techItem={itemType === 'Series' ? sampleEpisode.data : item}
tmdbVote={tmdbData?.vote_average}
tmdbVoteCount={tmdbData?.vote_count}
jellyfinCommunity={item.CommunityRating}
jellyfinCritic={item.CriticRating}
imdbRating={
cinemetaData?.imdbRating
? Number(cinemetaData.imdbRating)
: null
}
showTmdbRatings={showTmdbRatings}
seasonsCount={
itemType === 'Series'
? tmdbTvData?.number_of_seasons ?? null
: null
}
episodesCount={
itemType === 'Series'
? tmdbTvData?.number_of_episodes ?? null
: null
}
seriesStatus={itemType === 'Series' ? tmdbTvData?.status ?? null : null}
onRescan={
item.Id
? () =>
refresh.mutate({
itemId: item.Id!,
replaceAllMetadata: false,
replaceAllImages: false,
})
: undefined
}
isRescanning={refresh.isPending}
/>
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-b from-transparent to-void pointer-events-none" />
</div>
)
}
function TypePill({ itemType }: { itemType: string }) {
return (
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 self-start rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.12em] text-white/80">
{itemType === 'Series' || itemType === 'Episode' ? (
<Tv2 size={10} className="text-accent" />
) : itemType === 'MusicAlbum' || itemType === 'MusicArtist' ? (
<Disc3 size={10} className="text-accent" />
) : (
<FilmIcon size={10} className="text-accent" />
)}
{itemType === 'Series' ? 'Series' : itemType === 'Movie' ? 'Movie' : itemType}
</span>
)
}
function EpisodeBreadcrumb({ item, navigate }: { item: BaseItemDto; navigate: NavigateFunction }) {
return (
<button
onClick={() => navigate(`/item/${item.SeriesId}`)}
className="inline-flex items-center gap-2 mb-3 group focus-ring rounded"
>
<span className="text-[11px] font-semibold text-white/70 uppercase tracking-[0.18em] group-hover:text-white transition">
{item.SeriesName}
</span>
{item.ParentIndexNumber != null && item.IndexNumber != null && (
<span className="text-[11px] text-white/50 font-medium tabular-nums">
S{item.ParentIndexNumber} · E{item.IndexNumber}
</span>
)}
<ChevronRight size={12} className="text-white/40 group-hover:text-white/80 group-hover:translate-x-0.5 transition" />
</button>
)
}
function Dot() {
return <span className="text-white/30">·</span>
}
function IconButton({
children,
active,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
return (
<button
{...props}
className={`w-11 h-11 rounded-lg backdrop-blur grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 focus-ring disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 ${
active
? 'bg-accent/15 border border-accent/30'
: 'bg-white/10 border border-white/15 hover:bg-white/15'
}`}
>
{children}
</button>
)
}
@@ -0,0 +1,458 @@
import { useState } from 'react'
import { Tag } from '../../lib/icons'
import { Section } from '../ui/SectionLabel'
import type { BaseItemDto } from '../../api/types'
import type {
TmdbMovie,
TmdbTvShow,
TmdbCollection,
TmdbWatchProviders,
TmdbKeyword,
TmdbReview,
TmdbVideo,
} from '../../api/tmdb'
import type { CinemetaMeta } from '../../api/cinemeta'
import type { TvmazeShow } from '../../api/tvmaze'
import type { WikidataAward, WikidataLocation } from '../../api/wikidata'
import type { RTRating } from '../../api/rotten-tomatoes'
type WikiSection = { extract: string; title: string }
import { usePreferencesStore } from '../../stores/preferences-store'
import { useDiary } from '../../stores/diary-store'
import { parseImdbRating } from '../../lib/episode-meta'
import { hasTechSpecs } from './TechSpecs'
import VersionsSelector from './VersionsSelector'
import ReadingMode from './ReadingMode'
import SeriesSection from './SeriesSection'
import EpisodeExtras from './EpisodeExtras'
import CastList from './CastList'
import CrewGrid from './CrewGrid'
import ComposerBlock from './ComposerBlock'
import FactsPanel from './FactsPanel'
import WhereToWatch from './WhereToWatch'
import SeriesStatusBlock from './SeriesStatusBlock'
import VideosSection from './VideosSection'
import ProductionTrivia from './ProductionTrivia'
import ReceptionPanel from './ReceptionPanel'
import WatchTimeline from './WatchTimeline'
import FilmingLocationsMap from './FilmingLocationsMap'
import AwardsBlock from './AwardsBlock'
import TechSpecs from './TechSpecs'
import PersonalSection from './PersonalSection'
import DiarySection from './DiarySection'
import CollectionStrip from './CollectionStrip'
import ReviewsSection from './ReviewsSection'
import DiscoveryRows from './DiscoveryRows'
import FromSameRow from './FromSameRow'
import { getSeriesAirInfo } from '../../lib/item-types'
interface Props {
item: BaseItemDto
itemId: string | undefined
itemType: string
imdbId: string | null
tmdbId: string | null | undefined
tmdbMovieData: TmdbMovie | null | undefined
tmdbTvData: TmdbTvShow | null | undefined
tmdbCollectionData: TmdbCollection | null | undefined
cinemetaData: CinemetaMeta | null | undefined
tvmazeData: TvmazeShow | null | undefined
wikiProduction: WikiSection | null | undefined
awardsData: WikidataAward[] | null | undefined
locationsData: WikidataLocation[] | null | undefined
rtData: RTRating | null | undefined
/** Wikipedia article title for the item - the ReceptionPanel uses
* this to pull the Critical-response section. */
wikiTitle: string | null
region: string
watchProviders: TmdbWatchProviders | null | undefined
cast: unknown[]
crew: unknown[]
keywords: TmdbKeyword[]
reviews: TmdbReview[]
videos: TmdbVideo[] | undefined
recommendations: { id: number; adult?: boolean }[]
similar: { id: number; adult?: boolean }[]
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
overview: string
overviewSource: string | null
sources: { Id?: string }[]
activeSourceId: string | null
onSourceChange: (id: string | null) => void
}
export default function DetailMainSections({
item,
itemId,
itemType,
imdbId,
tmdbId,
tmdbMovieData,
tmdbTvData,
tmdbCollectionData,
cinemetaData,
tvmazeData,
wikiProduction,
awardsData,
locationsData,
rtData,
wikiTitle,
region,
watchProviders,
cast,
crew,
keywords,
reviews,
videos,
recommendations,
similar,
libraryMap,
overview,
overviewSource,
sources,
activeSourceId,
onSourceChange,
}: Props) {
const prefs = usePreferencesStore()
const hasDiaryEntries = useDiary(s => itemId ? s.entries.some(e => e.itemId === itemId) : false)
const [readingModeOpen, setReadingModeOpen] = useState(false)
return (
<>
<div className="relative px-7 pt-2 space-y-10">
{/* Versions selector when there's more than one source */}
{sources.length > 1 && (
<Section label="Versions">
<VersionsSelector
item={item}
selectedSourceId={activeSourceId}
onChange={onSourceChange}
/>
</Section>
)}
{/* Synopsis */}
{overview && (
<Section label="Overview" id="detail-overview">
<p className="text-[15px] text-text-2 leading-[1.7] max-w-[68ch] tracking-[-0.005em] [text-wrap:pretty]">
{overview}
</p>
<div className="mt-2 flex items-center gap-3 flex-wrap">
{overviewSource && (
<p className="text-[10.5px] text-text-4">Source: {overviewSource}</p>
)}
{overview.length > 600 && (
<button
onClick={() => setReadingModeOpen(true)}
className="text-[11.5px] text-accent hover:text-accent-hover transition tracking-tight focus-ring rounded"
>
Open in reader
</button>
)}
</div>
</Section>
)}
<ReadingMode
open={readingModeOpen}
onClose={() => setReadingModeOpen(false)}
title={item.Name || ''}
body={overview}
source={overviewSource}
/>
{/* Watch timeline: the user's personal history with this title.
Letterboxd-flavoured chronology of diary entries. Self-hides
when there's no diary history. */}
{hasDiaryEntries && itemId && (
<Section label="Your watches">
<WatchTimeline itemId={itemId} />
</Section>
)}
{/* Reception: aggregate of every available rating + critical
response prose. Replaces the ratings strip that used to live
inside the About / FactsPanel section. */}
{(itemType === 'Movie' || itemType === 'Series') && (
<Section label="Reception" id="detail-reception">
<ReceptionPanel
itemId={itemId}
rt={rtData}
imdbRating={parseImdbRating(cinemetaData?.imdbRating)}
tmdbScore={(itemType === 'Movie' ? tmdbMovieData?.vote_average : tmdbTvData?.vote_average) ?? null}
tmdbVotes={(itemType === 'Movie' ? tmdbMovieData?.vote_count : tmdbTvData?.vote_count) ?? null}
jellyfinCommunityRating={item.CommunityRating ?? null}
wikiTitle={wikiTitle}
/>
</Section>
)}
{/* Series episodes - lead with what the user came for on a series
page. Full-width because the table scrolls and packs many rows. */}
{itemType === 'Series' && itemId && (
<div id="detail-episodes" className="scroll-mt-4">
<SeriesSection seriesId={itemId} imdbId={imdbId} tmdbId={tmdbId ? Number(tmdbId) : null} />
</div>
)}
{/* Episode-specific extras (only on Episode pages) */}
{itemType === 'Episode' && prefs.detail.show.episodeExtras && (
<Section label="">
<EpisodeExtras item={item} />
</Section>
)}
{/* People row: Cast + Crew side-by-side */}
{(cast.length > 0 || crew.length > 0) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-10">
{cast.length > 0 && (
<Section label="Cast" id="detail-cast">
<CastList cast={cast as any[]} fallbackPeople={item.People} />
</Section>
)}
{crew.length > 0 && (
<Section label="Crew">
<CrewGrid crew={crew as any[]} />
<div className="mt-3">
<ComposerBlock crew={crew as any[]} />
</div>
</Section>
)}
</div>
)}
{/* "What is this" cluster: About + Where to Watch + Series Status
flow into a CSS multi-column layout. Items are packed by content
height (newspaper-style) so a tall About doesn't leave a 600px
void next to a short Where to Watch row. */}
{((itemType === 'Movie' || itemType === 'Series') || watchProviders) && (
<div className="columns-1 lg:columns-2 gap-8 [column-fill:balance]">
{(itemType === 'Movie' || itemType === 'Series') && (
<div className="break-inside-avoid mb-8">
<Section label="About">
<FactsPanel
itemType={itemType}
movie={tmdbMovieData || null}
tv={tmdbTvData || null}
region={region}
/>
</Section>
</div>
)}
{watchProviders && (
<div className="break-inside-avoid mb-8">
<WhereToWatch providers={watchProviders} defaultRegion={region} />
</div>
)}
{itemType === 'Series' && (
<div className="break-inside-avoid mb-8">
<Section label="Series status">
<SeriesStatusBlock
show={tmdbTvData ?? undefined}
tvmazeShow={tvmazeData || null}
jellyfinAirDays={getSeriesAirInfo(item).airDays}
jellyfinAirTime={getSeriesAirInfo(item).airTime}
/>
</Section>
</div>
)}
</div>
)}
{/* Videos */}
{prefs.detail.show.videos && (videos?.length ?? 0) > 0 && (
<Section label="Videos">
<VideosSection videos={videos} />
</Section>
)}
{/* Trivia */}
{prefs.detail.show.trivia && (wikiProduction?.extract || (keywords.length > 0 && wikiProduction)) && (
<Section label="Trivia" id="detail-trivia">
<ProductionTrivia
keywords={keywords}
wikiProduction={wikiProduction}
/>
</Section>
)}
{/* Filming locations */}
{prefs.detail.show.filmingLocations && (locationsData?.length ?? 0) > 0 && (
<Section label="Filming locations">
<FilmingLocationsMap locations={locationsData} />
</Section>
)}
{/* Activity cluster: Awards + Tech specs + Personal + Diary in a
multi-column flow so wildly different content heights pack
efficiently. */}
{((prefs.detail.show.awards && (awardsData?.length ?? 0) > 0)
|| hasTechSpecs(item)
|| keywords.length > 0
|| (item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series'))) && (
<div className="columns-1 lg:columns-2 gap-8 [column-fill:balance]">
{prefs.detail.show.awards && (awardsData?.length ?? 0) > 0 && (
<div className="break-inside-avoid mb-8">
<Section label="Awards">
<AwardsBlock awards={awardsData} />
</Section>
</div>
)}
{hasTechSpecs(item) && (
<div className="break-inside-avoid mb-8">
<Section label="Tech specs">
<TechSpecs item={item} />
</Section>
</div>
)}
{item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.personal && (
<div className="break-inside-avoid mb-8">
<Section label="Personal">
<PersonalSection itemId={item.Id} showRewatchToggle />
</Section>
</div>
)}
{item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.diary && (
<div className="break-inside-avoid mb-8">
<Section label="Diary">
<DiarySection itemId={item.Id} itemName={item.Name || 'Unknown'} />
</Section>
</div>
)}
{keywords.length > 0 && (
<div className="break-inside-avoid mb-8">
<Section label="Keywords">
<div className="flex flex-wrap gap-1.5">
{keywords.slice(0, 24).map(k => (
<span
key={k.id}
className="inline-flex items-center gap-1 h-6 px-2.5 bg-elevated/50 hover:bg-elevated border border-border hover:border-border-hover rounded-full text-[11px] text-text-2 hover:text-text-1 transition-colors cursor-default"
>
<Tag size={9} className="text-text-4" />
{k.name}
</span>
))}
</div>
</Section>
</div>
)}
</div>
)}
{/* Collection - the franchise / series strip */}
{tmdbMovieData?.belongs_to_collection && (
<Section label="Collection">
<CollectionStrip
collectionRef={tmdbMovieData.belongs_to_collection}
collection={tmdbCollectionData ?? undefined}
currentMovieId={tmdbMovieData.id}
/>
</Section>
)}
{/* Reviews */}
{reviews.length > 0 && (
<Section label="Reviews" id="detail-reviews">
<ReviewsSection reviews={reviews} />
</Section>
)}
</div>
<section id="detail-more" className="scroll-mt-4 mt-14 pt-8 border-t border-border/60">
<div className="px-7 mb-6">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">
More like this
</span>
</div>
<p className="text-[12.5px] text-text-3 max-w-xl">
Titles by the same people on this project plus algorithmic picks
you might want to queue up next.
</p>
</div>
<div className="space-y-2">
{/* "From the same..." rows: highlight other work by the headline
creator + a top-billed actor before the generic Discovery rows.
Each row self-hides when the person has fewer than 3 other
credits in the same media kind, so this stays scoped. */}
{(itemType === 'Movie' || itemType === 'Series') && (
<FromSameRowsCluster
itemType={itemType}
tmdbId={tmdbId}
cast={cast}
crew={crew}
libraryMap={libraryMap}
/>
)}
{/* Discovery rows - library matches first, then full lists */}
<DiscoveryRows
recommendations={recommendations}
similar={similar}
libraryMap={libraryMap}
/>
</div>
</section>
</>
)
}
/**
* Picks the headline director (or series creator) + the top-billed
* actor from the credits and renders a FromSameRow for each. Skips
* either row when there's nothing useful to render.
*/
function FromSameRowsCluster({
itemType,
tmdbId,
cast,
crew,
libraryMap,
}: {
itemType: string
tmdbId: string | null | undefined
cast: unknown[]
crew: unknown[]
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
}) {
const kind: 'movie' | 'tv' = itemType === 'Series' ? 'tv' : 'movie'
const crewList = crew as Array<{ id: number; name: string; job?: string; department?: string }>
const castList = cast as Array<{ id: number; name: string; order?: number }>
const director = crewList.find(c => c.job === 'Director')
// For series, "Director" is rarely meaningful (each episode has its
// own); use the executive producer / creator chain instead.
const creator = kind === 'tv'
? crewList.find(c => c.job === 'Executive Producer' || c.job === 'Creator')
: null
const headlinePerson = director || creator
const topActor = [...castList].sort((a, b) => (a.order ?? 999) - (b.order ?? 999))[0]
const excludeId = tmdbId ? Number(tmdbId) : null
return (
<>
{headlinePerson && (
<FromSameRow
personId={headlinePerson.id}
personName={headlinePerson.name}
role={director ? 'director' : 'writer'}
excludeTmdbId={excludeId}
kind={kind}
libraryMap={libraryMap}
/>
)}
{topActor && (
<FromSameRow
personId={topActor.id}
personName={topActor.name}
role="actor"
excludeTmdbId={excludeId}
kind={kind}
libraryMap={libraryMap}
/>
)}
</>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
export interface SectionTab {
id: string
label: string
}
interface Props {
/** Element ids the user can jump to, in display order. The component
* filters out ones that don't actually exist in the DOM at render
* time so the tab row mirrors what's on the page. */
tabs: SectionTab[]
}
/**
* Compact table-of-contents strip rendered just below the hero. Smooth-
* scrolls to the targeted section on click and keeps the active tab
* synced with the viewport via IntersectionObserver - useful on long
* series pages where the user might want to jump from Overview straight
* to Cast or More-like-this without scrolling.
*
* The tabs are NOT sticky - that role is owned by `DetailStickyBar` so
* the page doesn't end up with two competing top-anchored chromes.
*/
export default function DetailSectionTabs({ tabs }: Props) {
const [active, setActive] = useState<string | null>(null)
const [available, setAvailable] = useState<SectionTab[]>([])
// Filter to tabs whose target element actually exists in the DOM.
// Sections are conditionally rendered, so listing them all from the
// parent would surface dead links for sections that hid themselves.
useEffect(() => {
const present = tabs.filter(t => document.getElementById(t.id))
setAvailable(present)
}, [tabs])
// Spy on each section so the active tab matches what's mostly in view.
useEffect(() => {
if (available.length === 0) return
const root = document.querySelector('main.content-scroll') as HTMLElement | null
const observer = new IntersectionObserver(
entries => {
// Pick the highest entry that's in view - the one closest to the
// top of the viewport from the user's perspective.
const inView = entries
.filter(e => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (inView.length > 0) {
setActive(inView[0].target.id)
}
},
{
root,
// Activate when a section's top is near the top of the viewport,
// not when it just barely enters from below. The negative bottom
// margin makes the active zone the upper third of the viewport.
rootMargin: '-12% 0px -65% 0px',
threshold: 0,
},
)
for (const t of available) {
const el = document.getElementById(t.id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [available])
function scrollTo(id: string) {
const el = document.getElementById(id)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
setActive(id)
}
if (available.length < 2) return null
return (
<nav
aria-label="Sections"
className="relative -mx-7 px-7 overflow-x-auto hide-scrollbar"
>
<ul className="flex items-center gap-1 min-w-max">
{available.map(t => {
const isActive = active === t.id
return (
<li key={t.id}>
<button
type="button"
onClick={() => scrollTo(t.id)}
aria-current={isActive ? 'true' : undefined}
className={`relative h-10 px-3.5 inline-flex items-center text-[12.5px] font-medium tracking-tight transition-colors focus-ring rounded-md ${
isActive ? 'text-text-1' : 'text-text-3 hover:text-text-1'
}`}
>
{t.label}
{isActive && (
<motion.span
layoutId="detail-tab-underline"
className="absolute -bottom-px left-2 right-2 h-[2px] bg-accent rounded-t"
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
/>
)}
</button>
</li>
)
})}
</ul>
</nav>
)
}
+30
View File
@@ -0,0 +1,30 @@
export default function DetailSkeleton() {
return (
<div>
<div className="relative h-[68vh] min-h-[520px] -mt-14 overflow-hidden">
<div className="absolute inset-0 skeleton" />
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-void/10" />
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
<div className="relative h-full flex items-end pb-12 px-7 gap-8">
<div className="hidden md:block w-[200px] aspect-[2/3] skeleton rounded-xl shrink-0" />
<div className="flex-1 space-y-4">
<div className="skeleton h-5 w-24 rounded" />
<div className="skeleton h-12 w-2/3 rounded" />
<div className="skeleton h-4 w-1/3 rounded" />
<div className="flex gap-2 mt-2">
<div className="skeleton h-11 w-28 rounded-lg" />
<div className="skeleton h-11 w-32 rounded-lg" />
<div className="skeleton h-11 w-11 rounded-lg" />
</div>
</div>
</div>
</div>
<div className="px-7 py-6 space-y-6">
<div className="skeleton h-3 w-20 rounded" />
<div className="skeleton h-3 w-full rounded" />
<div className="skeleton h-3 w-full rounded" />
<div className="skeleton h-3 w-3/4 rounded" />
</div>
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { Play, Heart, Share2 } from '../../lib/icons'
import { buildShareUrl, copyToClipboard } from '../../lib/share'
import { toast } from '../../stores/toast-store'
import DetailSectionTabs, { type SectionTab } from './DetailSectionTabs'
interface Props {
/** Controlled visibility - parent decides whether the chrome is shown.
* Pass `usePastSentinel(sentinelRef)` for the standard behaviour. */
visible: boolean
title: string
posterUrl?: string | null
logoUrl?: string | null
progress?: number | null
isFavorite?: boolean | null
playUrl: string
resumeUrl: string
tmdbId?: string | number | null
imdbId?: string | null
itemType?: string
onFavoriteToggle?: () => void
/** Section tabs to render below the action row. Pass an empty array
* to hide the tab strip; pass a list to enable section nav. */
tabs?: SectionTab[]
}
/**
* Sticky top chrome for the detail page. Owns the slim action row
* (logo / title + Share / Favorite / Play) AND the section tabs
* underneath. Both appear together once the user has scrolled past
* the hero, animated in as a unit so the chrome doesn't look like two
* separate things flickering on screen.
*
* Visibility is fully controlled - the parent owns the sentinel + the
* `usePastSentinel` hook. This component just renders.
*/
export default function DetailStickyBar({
visible,
title,
posterUrl,
logoUrl,
progress,
isFavorite,
playUrl,
resumeUrl,
tmdbId,
imdbId,
itemType,
onFavoriteToggle,
tabs,
}: Props) {
const navigate = useNavigate()
const hasProgress = typeof progress === 'number' && progress > 0
const hasTabs = !!tabs && tabs.length > 1
async function onShare() {
const url = buildShareUrl({
tmdbId,
imdbId,
kind: itemType === 'Series' || itemType === 'Episode' ? 'tv' : 'movie',
})
if (!url) {
toast('No shareable link for this item', 'error')
return
}
const ok = await copyToClipboard(url)
toast(ok ? 'Link copied to clipboard' : 'Could not copy link', ok ? 'success' : 'error')
}
return (
<div className="sticky top-0 z-30 h-0">
<AnimatePresence>
{visible && (
<motion.div
key="sticky-chrome"
initial={{ y: -120, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -120, opacity: 0 }}
transition={{ duration: 0.26, ease: [0.16, 1, 0.3, 1] }}
className="absolute top-0 left-0 right-0 bg-void/92 backdrop-blur-xl border-b border-border/80 shadow-[0_8px_20px_-12px_rgba(0,0,0,0.6)]"
>
<div className="h-14 flex items-center gap-3 px-7">
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-7 max-w-[120px] object-contain"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : posterUrl ? (
<img
src={posterUrl}
alt=""
className="h-8 aspect-[2/3] rounded object-cover ring-1 ring-border"
/>
) : null}
<span className="flex-1 min-w-0 text-[13px] font-semibold text-text-1 tracking-tight truncate">
{logoUrl ? '' : title}
</span>
<div className="shrink-0 flex items-center gap-1.5">
<button
onClick={onShare}
aria-label="Copy share link"
title="Copy share link"
className="w-9 h-9 grid place-items-center rounded-full text-text-2 hover:text-text-1 hover:bg-white/8 transition-colors focus-ring"
>
<Share2 size={15} />
</button>
{onFavoriteToggle && (
<button
onClick={onFavoriteToggle}
aria-label={isFavorite ? 'Remove favorite' : 'Add favorite'}
className="w-9 h-9 grid place-items-center rounded-full text-text-2 hover:text-text-1 hover:bg-white/8 transition-colors focus-ring"
>
<Heart
size={15}
className={isFavorite ? 'text-accent fill-accent' : ''}
fill={isFavorite ? 'currentColor' : 'none'}
/>
</button>
)}
{playUrl && (
<button
onClick={() => navigate(hasProgress ? resumeUrl : playUrl)}
className="inline-flex items-center gap-1.5 h-9 px-3.5 rounded-full bg-accent hover:bg-accent-hover text-void text-[12.5px] font-semibold tracking-tight transition-all duration-150 hover:scale-[1.03] active:scale-[0.97] focus-ring"
>
<Play size={12} fill="currentColor" stroke={0} />
{hasProgress ? 'Resume' : 'Play'}
</button>
)}
</div>
</div>
{hasTabs && (
<div className="px-7">
<DetailSectionTabs tabs={tabs!} />
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Star, Trash2, X } from '../../lib/icons'
import { useDiary, type DiaryEntry } from '../../stores/diary-store'
interface Props {
itemId: string
itemName: string
}
const QUICK_EMOJI = ['🎬', '🍿', '😢', '😱', '🤯', '😴', '❤️', '🤩', '🤔', '😂', '👀', '🔥']
/**
* Per-item diary log. Shows chronological entries for this item with
* optional rating + emoji + note, plus a "Log a watch" button that
* opens an inline editor for a new entry.
*
* Editing works in-place with a small drawer below each entry.
*/
export default function DiarySection({ itemId, itemName }: Props) {
// Select the whole array; filter + sort in render via useMemo.
// A selector that returns .filter()/.sort() output creates a new
// reference each call and trips useSyncExternalStore's loop guard.
const allEntries = useDiary(s => s.entries)
const add = useDiary(s => s.add)
const remove = useDiary(s => s.remove)
const [composing, setComposing] = useState(false)
const sorted = useMemo(
() =>
allEntries
.filter(e => e.itemId === itemId)
.sort((a, b) => b.watchedAt.localeCompare(a.watchedAt)),
[allEntries, itemId],
)
function startCompose() {
setComposing(true)
}
function commitNew(payload: { rating?: number; note?: string; emoji?: string; rewatch?: boolean }) {
add({ itemId, itemName, ...payload })
setComposing(false)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12.5px] text-text-3 leading-relaxed">
{sorted.length === 0
? "You haven't logged a watch yet for this item."
: `${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`}
</p>
<button
onClick={startCompose}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent text-void text-[12px] font-semibold tracking-tight hover:bg-accent-hover transition focus-ring"
>
<Plus size={12} stroke={2.5} />
Log a watch
</button>
</div>
<AnimatePresence>
{composing && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<Composer onCancel={() => setComposing(false)} onSave={commitNew} />
</motion.div>
)}
</AnimatePresence>
{sorted.length > 0 && (
<ol className="space-y-2 mt-1">
{sorted.map(entry => (
<DiaryRow key={entry.id} entry={entry} onRemove={() => remove(entry.id)} />
))}
</ol>
)}
</div>
)
}
function Composer({
onCancel,
onSave,
}: {
onCancel: () => void
onSave: (payload: { rating?: number; note?: string; emoji?: string; rewatch?: boolean }) => void
}) {
const [rating, setRating] = useState(0)
const [note, setNote] = useState('')
const [emoji, setEmoji] = useState<string | undefined>(undefined)
const [rewatch, setRewatch] = useState(false)
return (
<div className="rounded-xl bg-elevated/40 ring-1 ring-border p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
{QUICK_EMOJI.map(g => (
<button
key={g}
onClick={() => setEmoji(emoji === g ? undefined : g)}
className={`w-8 h-8 grid place-items-center rounded-md text-[16px] transition ${
emoji === g ? 'bg-accent/15 ring-1 ring-accent/30' : 'hover:bg-elevated/60'
}`}
>
{g}
</button>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mr-2">
Rating
</span>
{Array.from({ length: 10 }).map((_, i) => {
const n = i + 1
const on = n <= rating
return (
<button
key={n}
onClick={() => setRating(rating === n ? 0 : n)}
aria-label={`Rate ${n} out of 10`}
className={`w-6 h-6 grid place-items-center rounded transition ${
on ? 'text-accent' : 'text-text-4 hover:text-text-2'
}`}
>
<Star size={12} fill={on ? 'currentColor' : 'none'} stroke={on ? 0 : 1.5} />
</button>
)
})}
{rating > 0 && (
<span className="text-[11px] text-text-3 tabular-nums ml-1">{rating}/10</span>
)}
</div>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={2}
placeholder="A thought, a quote, or just nothing"
className="w-full px-3 py-2 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight resize-y leading-relaxed text-text-1 placeholder:text-text-4"
/>
<div className="flex items-center justify-between gap-2 flex-wrap">
<label className="inline-flex items-center gap-1.5 text-[11.5px] text-text-2 cursor-pointer">
<input
type="checkbox"
checked={rewatch}
onChange={e => setRewatch(e.target.checked)}
className="accent-amber-500"
/>
Logged as a rewatch
</label>
<div className="flex items-center gap-2">
<button
onClick={onCancel}
className="h-8 px-3 rounded-full text-[11.5px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
>
Cancel
</button>
<button
onClick={() => onSave({ rating: rating || undefined, note: note.trim() || undefined, emoji, rewatch })}
className="h-8 px-4 rounded-full bg-accent text-void text-[11.5px] font-semibold tracking-tight hover:bg-accent-hover transition focus-ring"
>
Log
</button>
</div>
</div>
</div>
)
}
function DiaryRow({ entry, onRemove }: { entry: DiaryEntry; onRemove: () => void }) {
const [confirmingDelete, setConfirmingDelete] = useState(false)
const date = new Date(entry.watchedAt)
const dateLabel = date.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
})
return (
<li className="rounded-lg bg-elevated/40 ring-1 ring-border p-3 group">
<div className="flex items-start gap-3">
{entry.emoji && (
<span className="text-[20px] leading-none mt-0.5 select-none">{entry.emoji}</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap text-[11.5px] text-text-3 mb-1 tracking-tight">
<span className="text-text-2 font-medium tabular-nums">{dateLabel}</span>
{entry.rating != null && entry.rating > 0 && (
<>
<span className="text-text-5">·</span>
<span className="inline-flex items-center gap-1 text-accent tabular-nums">
<Star size={10} fill="currentColor" stroke={0} />
{entry.rating}/10
</span>
</>
)}
{entry.rewatch && (
<>
<span className="text-text-5">·</span>
<span className="text-text-4">rewatch</span>
</>
)}
</div>
{entry.note && (
<p className="text-[12.5px] text-text-1 leading-relaxed whitespace-pre-wrap">
{entry.note}
</p>
)}
</div>
<button
onClick={() => {
if (confirmingDelete) onRemove()
else setConfirmingDelete(true)
}}
onBlur={() => setConfirmingDelete(false)}
aria-label={confirmingDelete ? 'Confirm delete' : 'Delete entry'}
className={`w-7 h-7 grid place-items-center rounded transition focus-ring ${
confirmingDelete
? 'text-red-300 bg-red-500/15'
: 'text-text-4 hover:text-red-300 opacity-0 group-hover:opacity-100'
}`}
>
{confirmingDelete ? <X size={12} stroke={2.5} /> : <Trash2 size={12} stroke={2} />}
</button>
</div>
</li>
)
}
+53
View File
@@ -0,0 +1,53 @@
import ContentRow from '../ui/ContentRow'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
interface Props {
recommendations: any[]
similar: any[]
libraryMap?: Map<string, { id: string; name: string; type: string }>
}
export default function DiscoveryRows({ recommendations, similar, libraryMap }: Props) {
// Combined pool for "in library" - dedup by tmdb id
const seen = new Set<string>()
const pool = [...recommendations, ...similar].filter(m => {
if (seen.has(String(m.id))) return false
seen.add(String(m.id))
return true
})
const inLibrary = libraryMap
? pool.filter(m => libraryMap.has(String(m.id)))
: []
return (
<>
{inLibrary.length > 0 && (
<div className="mt-8">
<ContentRow
title="In your library"
subtitle="Related titles you already own"
items={mapTmdbToJf(inLibrary, libraryMap)}
/>
</div>
)}
{recommendations.length > 0 && (
<div className="mt-2">
<ContentRow
title="You might like"
subtitle="Recommendations from TMDB"
items={mapTmdbToJf(recommendations, libraryMap)}
/>
</div>
)}
{similar.length > 0 && (
<div className="mt-2">
<ContentRow
title="More like this"
subtitle="Algorithmically similar"
items={mapTmdbToJf(similar, libraryMap)}
/>
</div>
)}
</>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { motion } from 'framer-motion'
import { useTmdbEpisode, useTmdbTvShow } from '../../hooks/use-tmdb'
import { useItemDetails } from '../../hooks/use-jellyfin'
import { getTmdbImageUrl } from '../../api/tmdb'
import type { BaseItemDto } from '../../api/types'
import HorizontalScroller from '../ui/HorizontalScroller'
import ReviewsSection from './ReviewsSection'
interface Props {
item: BaseItemDto
}
/**
* Extra sections rendered on episode-type detail pages:
*
* 1. **Featured in this episode** - per-episode TMDB cast filtered to the
* guest stars (people not in the main show ensemble). Falls back to
* nothing if TMDB doesn't have credits for the episode.
* 2. **Directed / Written by** - inline line below the cast row.
* 3. **Series reviews** - the show's TMDB reviews with a "from the
* series" caption, since TMDB doesn't expose per-episode reviews.
*
* Renders nothing if the parent series has no TMDB id, so non-TMDB
* libraries are unaffected.
*/
export default function EpisodeExtras({ item }: Props) {
const seriesJfId = item.SeriesId ?? undefined
const { data: seriesItem } = useItemDetails(seriesJfId)
const seriesTmdbId = seriesItem?.ProviderIds?.Tmdb
const seriesTmdbNum = seriesTmdbId ? Number(seriesTmdbId) : null
const seasonNum = item.ParentIndexNumber ?? undefined
const episodeNum = item.IndexNumber ?? undefined
const tvShow = useTmdbTvShow(seriesTmdbNum)
const episode = useTmdbEpisode(seriesTmdbNum, seasonNum, episodeNum)
if (!seriesTmdbNum) return null
const credits = episode.data?.credits
const mainCastIds = new Set((tvShow.data?.credits?.cast || []).map(c => c.id))
// Surface guest stars first; pad with episode-specific cast that aren't
// in the main ensemble. Cap at 8.
const guests = (credits?.cast || []).filter(c => !mainCastIds.has(c.id)).slice(0, 8)
const crew = credits?.crew || []
const director = crew.find(c => c.job === 'Director')
const writer = crew.find(
c => c.job === 'Writer' || c.job === 'Screenplay' || c.job === 'Story',
)
const reviews = tvShow.data?.reviews?.results || []
return (
<>
{(guests.length > 0 || director || writer) && (
<motion.section
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-3 leading-none">
Featured in this episode
</p>
{guests.length > 0 && (
<div className="-mx-7 mb-2">
<HorizontalScroller gap="gap-3" arrowsBottomInset="bottom-12">
{guests.map(c => (
<div key={c.id} className="shrink-0 w-[112px]">
<div className="aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border mb-1.5">
{c.profile_path ? (
<img
src={getTmdbImageUrl(c.profile_path, 'w185')}
alt={c.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-4 text-2xl font-display font-semibold opacity-40">
{c.name?.[0]?.toUpperCase() || '?'}
</div>
)}
</div>
<p className="text-[12px] text-text-1 font-medium leading-tight tracking-tight truncate">
{c.name}
</p>
{c.character && (
<p className="text-[10.5px] text-text-3 leading-tight truncate">
{c.character}
</p>
)}
</div>
))}
</HorizontalScroller>
</div>
)}
{(director || writer) && (
<p className="text-[12px] text-text-3 leading-relaxed">
{director && (
<>
Directed by <span className="text-text-1 font-medium">{director.name}</span>
</>
)}
{director && writer && <span className="text-text-5 mx-2">·</span>}
{writer && (
<>
Written by <span className="text-text-1 font-medium">{writer.name}</span>
</>
)}
</p>
)}
</motion.section>
)}
{reviews.length > 0 && (
<section>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1 leading-none">
Reviews
</p>
<p className="text-[11px] text-text-4 mb-4">From the series</p>
<ReviewsSection reviews={reviews} />
</section>
)}
</>
)
}
@@ -0,0 +1,92 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { EPISODE_GROUP_TYPES, type OrderingOption } from '../../lib/episode-order'
import type { TmdbEpisodeGroup } from '../../api/tmdb'
interface Props {
groups: TmdbEpisodeGroup[]
selectedId: string
onChange: (id: string) => void
}
/**
* Compact dropdown that lets users switch between aired order and any
* alternative TMDB episode groups. Filters out tiny groups (<5
* episodes) since those are usually fragments, not real orderings.
*/
export default function EpisodeOrderToggle({ groups, selectedId, onChange }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const options: OrderingOption[] = [
{ id: 'aired', label: 'Aired order' },
...groups
.filter(g => g.episode_count >= 5 && g.type !== 1)
.map(g => ({
id: g.id,
label: g.name || EPISODE_GROUP_TYPES[g.type] || 'Alternate',
description: EPISODE_GROUP_TYPES[g.type],
})),
]
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
window.addEventListener('mousedown', handler)
return () => window.removeEventListener('mousedown', handler)
}, [open])
const current = options.find(o => o.id === selectedId) || options[0]
if (options.length <= 1) return null
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg bg-elevated/60 ring-1 ring-border hover:ring-border-strong text-[11.5px] text-text-2 hover:text-text-1 transition tracking-tight"
>
<svg className="w-3 h-3 opacity-70" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h4a1 1 0 110 2H4a1 1 0 01-1-1z" />
</svg>
<span className="font-medium">{current.label}</span>
<svg className={`w-3 h-3 opacity-60 transition ${open ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 7.5L10 12l4.5-4.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-full mt-1.5 z-30 min-w-[180px] rounded-xl bg-surface/95 backdrop-blur-md ring-1 ring-border-strong shadow-xl overflow-hidden"
>
{options.map(opt => (
<button
key={opt.id}
type="button"
onClick={() => {
onChange(opt.id)
setOpen(false)
}}
className={`w-full text-left px-3 py-2 text-[12px] hover:bg-elevated/80 transition ${
opt.id === selectedId ? 'text-accent' : 'text-text-2'
}`}
>
<div className="font-medium leading-tight">{opt.label}</div>
{opt.description && opt.description !== opt.label && (
<div className="text-[10px] text-text-4 mt-0.5">{opt.description}</div>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,31 @@
import { Star } from '../../lib/icons'
import { useCinemetaEpisode } from '../../lib/episode-meta-context'
import { parseImdbRating } from '../../lib/episode-meta'
interface Props {
season: number | null | undefined
episode: number | null | undefined
/** Tuned for inline placement next to small meta text. */
size?: 'sm' | 'md'
}
/**
* Yellow IMDB pill rendered next to episode meta when Cinemeta has a
* rating for this S/E. Silently renders nothing when the data is missing,
* so it adds zero clutter to series Cinemeta hasn't indexed.
*/
export default function EpisodeRatingChip({ season, episode, size = 'sm' }: Props) {
const cinemeta = useCinemetaEpisode(season, episode)
const rating = parseImdbRating(cinemeta?.imdbRating)
if (rating == null) return null
const cls =
size === 'sm'
? 'inline-flex items-center gap-1 h-4 px-1.5 rounded text-[10px] font-mono font-semibold text-yellow-300 bg-yellow-500/10 ring-1 ring-yellow-500/20 tabular-nums'
: 'inline-flex items-center gap-1 h-5 px-1.5 rounded text-[11px] font-mono font-semibold text-yellow-300 bg-yellow-500/10 ring-1 ring-yellow-500/20 tabular-nums'
return (
<span className={cls} title="IMDB rating (via Cinemeta)" aria-label={`IMDB rating ${rating.toFixed(1)}`}>
<Star size={size === 'sm' ? 9 : 10} className="text-yellow-400 fill-yellow-400" />
{rating.toFixed(1)}
</span>
)
}
+208
View File
@@ -0,0 +1,208 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Play, Check, ChevronRight, CloudOff } from '../../lib/icons'
import { useBulkMarkPlayed } from '../../hooks/use-jellyfin'
import { usePreferencesStore } from '../../stores/preferences-store'
import { formatRuntime } from '../../lib/format'
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
import type { BaseItemDto } from '../../api/types'
import EpisodeRatingChip from './EpisodeRatingChip'
import FillerChip from './FillerChip'
import MetaBadges from '../ui/MetaBadges'
import SwipeReveal from '../ui/SwipeReveal'
interface Props {
episode: BaseItemDto
index: number
}
export default function EpisodeRow({ episode, index }: Props) {
const serverUrl = getStoredServerUrl()
const navigate = useNavigate()
const [thumbErrored, setThumbErrored] = useState(false)
const spoilerBlur = usePreferencesStore(s => s.episode.show.spoilerBlur)
const showFillerChips = usePreferencesStore(s => s.episode.show.fillerChips)
const showRatingChips = usePreferencesStore(s => s.episode.show.ratingChips)
const swipeActionsEnabled = usePreferencesStore(s => s.episode.behavior.swipeActions)
const markPlayed = useBulkMarkPlayed()
const thumbUrl = getBestImage(serverUrl, episode, 'thumb', 360)
const showThumb = thumbUrl && !thumbErrored
const isWatched = episode.UserData?.Played
const progress = episode.UserData?.PlayedPercentage
const runtime = episode.RunTimeTicks ? formatRuntime(episode.RunTimeTicks) : null
// Missing = file not on disk. Jellyfin's canonical signal is LocationType:
// "Virtual" means aired but not acquired, "FileSystem" means we have the
// file. Trusting LocationType matches what Jellyfin's own UI does.
const isMissing = (episode as { LocationType?: string }).LocationType === 'Virtual'
function goToDetail() {
if (!episode.Id) return
navigate(`/item/${episode.Id}`)
}
function goToPlay(e: React.MouseEvent) {
e.stopPropagation()
if (isMissing || !episode.Id) return
navigate(`/play/${episode.Id}`)
}
return (
<SwipeReveal
enabled={swipeActionsEnabled}
actions={episode.Id && !isMissing ? [
{
label: isWatched ? 'Unwatched' : 'Watched',
tone: isWatched ? 'neutral' : 'accent',
onPress: () => {
if (!episode.Id) return
markPlayed.mutate({ itemIds: [episode.Id], played: !isWatched })
},
},
] : []}
>
<motion.div
onClick={goToDetail}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
goToDetail()
}
}}
role="button"
tabIndex={0}
aria-label={`View ${episode.Name || 'episode'} details`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(index * 0.025, 0.3), ease: [0.16, 1, 0.3, 1] }}
className={`w-full group flex gap-4 p-3 rounded-lg transition-all duration-200 text-left focus-ring cursor-pointer ${
isMissing ? 'opacity-55' : 'hover:bg-elevated/60'
}`}
>
<button
type="button"
onClick={goToPlay}
disabled={isMissing}
aria-label={isMissing ? 'Episode missing' : `Play ${episode.Name || 'episode'}`}
className={`w-[180px] aspect-video rounded-md overflow-hidden bg-elevated shrink-0 relative focus-ring ${
isMissing ? 'ring-1 ring-error/25 cursor-not-allowed' : 'ring-1 ring-border cursor-pointer'
}`}
>
{showThumb ? (
<img
src={thumbUrl}
alt=""
onError={() => setThumbErrored(true)}
className={`w-full h-full object-cover transition-[transform,filter] duration-500 group-hover:scale-105 ${
spoilerBlur && !isWatched ? 'blur-md saturate-50 group-hover:blur-0 group-hover:saturate-100' : ''
}`}
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
<span className="text-text-4 text-2xl font-display font-semibold opacity-50 select-none">
{episode.IndexNumber != null ? `E${episode.IndexNumber}` : (episode.Name || '?')[0]?.toUpperCase()}
</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
{!isMissing && (
<div className="absolute inset-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="w-11 h-11 rounded-full bg-accent grid place-items-center shadow-lg shadow-black/40">
<Play size={16} className="text-void translate-x-0.5" fill="currentColor" />
</div>
</div>
)}
{isMissing && (
<div className="absolute inset-0 grid place-items-center bg-black/40">
<div className="flex flex-col items-center gap-1 text-error">
<CloudOff size={20} />
<span className="text-[9px] font-bold uppercase tracking-[0.12em]">Missing</span>
</div>
</div>
)}
{progress != null && progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-black/30">
<div
className="h-full bg-accent shadow-[0_0_6px_rgba(245,182,66,0.5)]"
style={{ width: `${progress}%` }}
/>
</div>
)}
<div className="absolute top-1.5 right-1.5">
<MetaBadges item={episode} />
</div>
</button>
<div className="flex-1 min-w-0 py-0.5">
<div className="flex items-baseline gap-2.5 mb-1.5">
{episode.IndexNumber != null && (
<span className="text-accent/80 text-[11px] tabular-nums font-mono font-semibold tracking-wider shrink-0">
{String(episode.IndexNumber).padStart(2, '0')}
</span>
)}
<span className="text-[14.5px] text-text-1 font-semibold tracking-tight leading-tight truncate">
{episode.Name}
</span>
{isWatched && (
<span className="shrink-0 w-4 h-4 rounded-full bg-accent grid place-items-center self-center">
<Check size={9} className="text-void" strokeWidth={3} />
</span>
)}
{isMissing && (
<span className="shrink-0 inline-flex items-center gap-1 h-4 px-1.5 rounded bg-error/15 text-error text-[9px] font-bold uppercase tracking-[0.06em] self-center">
<CloudOff size={8} />
Missing
</span>
)}
{showFillerChips && (
<span className="shrink-0 self-center">
<FillerChip
season={episode.ParentIndexNumber ?? null}
episode={episode.IndexNumber ?? null}
/>
</span>
)}
<span className="ml-auto flex items-center gap-2 shrink-0 text-[11px] text-text-4 tabular-nums">
{showRatingChips && (
<EpisodeRatingChip
season={episode.ParentIndexNumber ?? null}
episode={episode.IndexNumber ?? null}
/>
)}
{episode.PremiereDate && (
<span>
{new Date(episode.PremiereDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
{episode.PremiereDate && runtime && <span className="text-text-5">·</span>}
{runtime && <span>{runtime}</span>}
</span>
</div>
{episode.Overview && (
<p
className={`text-[12.5px] text-text-3 leading-[1.65] line-clamp-2 max-w-[78ch] tracking-[-0.005em] [text-wrap:pretty] transition-[filter,color] duration-200 ${
spoilerBlur && !isWatched
? 'blur-[3px] text-text-4 group-hover:blur-0 group-hover:text-text-3'
: ''
}`}
>
{episode.Overview}
</p>
)}
</div>
<ChevronRight
size={18}
className="text-text-4 self-center opacity-0 group-hover:opacity-100 transition-all duration-200 -translate-x-1 group-hover:translate-x-0"
/>
</motion.div>
</SwipeReveal>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { ExternalLink } from '../../lib/icons'
import type { TmdbExternalIds } from '../../api/tmdb'
interface Props {
tmdbId?: string | number | null
type?: 'movie' | 'tv'
ids?: TmdbExternalIds | null
jellyfinExternalUrls?: { Name?: string | null; Url?: string | null }[] | null
}
export default function ExternalLinks({ tmdbId, type, ids, jellyfinExternalUrls }: Props) {
const links: { label: string; href: string; primary?: boolean }[] = []
if (ids?.imdb_id) {
links.push({ label: 'IMDb', href: `https://www.imdb.com/title/${ids.imdb_id}/`, primary: true })
}
if (tmdbId) {
links.push({
label: 'TMDB',
href: `https://www.themoviedb.org/${type === 'tv' ? 'tv' : 'movie'}/${tmdbId}`,
})
}
if (ids?.wikidata_id) {
links.push({ label: 'Wikidata', href: `https://www.wikidata.org/wiki/${ids.wikidata_id}` })
}
if (ids?.tvdb_id) {
links.push({ label: 'TVDB', href: `https://thetvdb.com/?tab=series&id=${ids.tvdb_id}` })
}
for (const u of jellyfinExternalUrls || []) {
if (u.Url && u.Name && !links.some(l => l.label === u.Name)) {
links.push({ label: u.Name, href: u.Url })
}
}
if (!links.length) return null
return (
<div className="flex items-center gap-1.5 flex-wrap">
{links.map(l => (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-1.5 h-8 px-2.5 bg-white/8 hover:bg-white/15 backdrop-blur border border-white/10 hover:border-white/20 rounded-md text-[11.5px] text-white/80 hover:text-white font-medium transition-all duration-150 focus-ring"
>
{l.label}
<ExternalLink size={11} className="text-white/45 group-hover:text-white/75 transition-colors" />
</a>
))}
</div>
)
}
+300
View File
@@ -0,0 +1,300 @@
import { useMemo } from 'react'
import { Calendar, Cloud, Disc3, Star, Building2, Globe, Languages } from '../../lib/icons'
import {
countryLabel,
flagEmoji,
formatLongDate,
formatUsd,
languageLabel,
} from '../../lib/format'
import { getTmdbImageUrl, type TmdbMovie, type TmdbTvShow } from '../../api/tmdb'
import BrandLogo from '../ui/BrandLogo'
interface Props {
itemType: 'Movie' | 'Series' | 'Episode' | string | undefined
movie?: TmdbMovie | null
tv?: TmdbTvShow | null
region: string
}
interface ReleaseSplit {
theatrical: string | null
digital: string | null
physical: string | null
premiere: string | null
}
function pickReleases(movie: TmdbMovie, region: string): ReleaseSplit {
const empty: ReleaseSplit = {
theatrical: null,
digital: null,
physical: null,
premiere: null,
}
const all = movie.release_dates?.results || []
const regional = all.find(r => r.iso_3166_1 === region)
const us = all.find(r => r.iso_3166_1 === 'US')
const list = regional?.release_dates || us?.release_dates || []
const out = { ...empty }
for (const d of list) {
// type 1 Premiere, 2 Theatrical limited, 3 Theatrical, 4 Digital,
// 5 Physical, 6 TV
if (d.type === 3 && !out.theatrical) out.theatrical = d.release_date
if (d.type === 2 && !out.theatrical) out.theatrical = d.release_date
if (d.type === 4 && !out.digital) out.digital = d.release_date
if (d.type === 5 && !out.physical) out.physical = d.release_date
if (d.type === 1 && !out.premiere) out.premiere = d.release_date
}
return out
}
export default function FactsPanel({ itemType, movie, tv, region }: Props) {
const releases = useMemo(
() => (movie ? pickReleases(movie, region) : null),
[movie, region],
)
const isMovie = itemType === 'Movie' && !!movie
const isSeries = itemType === 'Series' && !!tv
if (!isMovie && !isSeries) return null
const status = isMovie ? movie?.status : tv?.status
const originalLanguage = isMovie ? movie?.original_language : tv?.original_language
const productionCountries = (isMovie
? movie?.production_countries
: tv?.production_countries) || []
const productionCompanies = (isMovie
? movie?.production_companies
: tv?.production_companies) || []
const networks = isSeries ? tv?.networks || [] : []
const tagline = isMovie ? movie?.tagline : tv?.tagline
const homepage = isMovie ? movie?.homepage : tv?.homepage
const originalTitle = isMovie
? movie?.original_title && movie.original_title !== movie.title
? movie.original_title
: null
: tv?.original_name && tv?.original_name !== tv?.name
? tv.original_name
: null
const seriesType = isSeries ? tv?.type : null
const inProduction = isSeries ? tv?.in_production : null
const numberOfSeasons = isSeries ? tv?.number_of_seasons : null
const numberOfEpisodes = isSeries ? tv?.number_of_episodes : null
const firstAir = isSeries ? tv?.first_air_date : null
const lastAir = isSeries ? tv?.last_air_date : null
return (
<div className="rounded-xl bg-elevated/40 border border-border overflow-hidden">
{/* Tagline header (if any) */}
{tagline && (
<div className="px-5 pt-5 pb-4 border-b border-border/60">
<p className="font-display text-[15px] italic text-text-2 leading-snug tracking-[-0.005em]">
"{tagline}"
</p>
</div>
)}
{/* Ratings now live in the dedicated Reception section near the
top of the page. About panel sticks to facts: dates, budgets,
studios, languages, etc. */}
{/* Facts list */}
<dl className="divide-y divide-border/60">
{originalTitle && (
<Row label="Original title">
<span className="text-text-1">{originalTitle}</span>
</Row>
)}
{status && (
<Row label="Status">
<span className={`inline-flex items-center gap-1.5 ${
status === 'Released' || status === 'Ended'
? 'text-text-1'
: 'text-accent'
}`}>
{(isSeries && inProduction) ||
status === 'In Production' ||
status === 'Returning Series' ||
status === 'Planned' ? (
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
) : null}
{status}
</span>
</Row>
)}
{seriesType && (
<Row label="Type">
<span className="text-text-1">{seriesType}</span>
</Row>
)}
{/* Movie release dates */}
{isMovie && releases && (releases.theatrical || releases.digital || releases.physical || releases.premiere) && (
<Row label="Release dates" alignTop>
<div className="flex flex-col gap-1.5 text-text-1">
{releases.theatrical && (
<DateLine icon={<Calendar size={12} />} label="Theatrical" date={releases.theatrical} />
)}
{releases.digital && (
<DateLine icon={<Cloud size={12} />} label="Digital" date={releases.digital} />
)}
{releases.physical && (
<DateLine icon={<Disc3 size={12} />} label="Physical" date={releases.physical} />
)}
{releases.premiere && !releases.theatrical && (
<DateLine icon={<Star size={12} />} label="Premiere" date={releases.premiere} />
)}
</div>
</Row>
)}
{/* Series air dates */}
{isSeries && firstAir && (
<Row label="First air date">
<span className="text-text-1">{formatLongDate(firstAir)}</span>
</Row>
)}
{isSeries && lastAir && lastAir !== firstAir && (
<Row label="Last air date">
<span className="text-text-1">{formatLongDate(lastAir)}</span>
</Row>
)}
{isSeries && (numberOfSeasons || numberOfEpisodes) && (
<Row label="Run length">
<span className="text-text-1 tabular-nums">
{numberOfSeasons ? `${numberOfSeasons} season${numberOfSeasons === 1 ? '' : 's'}` : ''}
{numberOfSeasons && numberOfEpisodes ? ' · ' : ''}
{numberOfEpisodes ? `${numberOfEpisodes} episode${numberOfEpisodes === 1 ? '' : 's'}` : ''}
</span>
</Row>
)}
{/* Money */}
{isMovie && movie?.revenue ? (
<Row label="Revenue">
<span className="text-text-1 tabular-nums">{formatUsd(movie.revenue)}</span>
</Row>
) : null}
{isMovie && movie?.budget ? (
<Row label="Budget">
<span className="text-text-1 tabular-nums">{formatUsd(movie.budget)}</span>
</Row>
) : null}
{/* Language + country */}
{originalLanguage && (
<Row label="Original language">
<span className="inline-flex items-center gap-1.5 text-text-1">
<Languages size={12} className="text-text-3" />
{languageLabel(originalLanguage)}
</span>
</Row>
)}
{productionCountries.length > 0 && (
<Row label={productionCountries.length > 1 ? 'Production countries' : 'Production country'} alignTop>
<div className="flex flex-col gap-0.5">
{productionCountries.slice(0, 4).map(c => (
<span key={c.iso_3166_1} className="inline-flex items-center gap-1.5 text-text-1">
<span aria-hidden className="text-[14px] leading-none">
{flagEmoji(c.iso_3166_1) || <Globe size={12} className="text-text-3" />}
</span>
{countryLabel(c.iso_3166_1) || c.name}
</span>
))}
</div>
</Row>
)}
{/* Studios + networks */}
{productionCompanies.length > 0 && (
<Row label={productionCompanies.length > 1 ? 'Studios' : 'Studio'} alignTop>
<div className="flex flex-col gap-1.5">
{productionCompanies.slice(0, 4).map(c => (
<span key={c.id} className="inline-flex items-center gap-2 text-text-1">
{c.logo_path ? (
<BrandLogo
src={getTmdbImageUrl(c.logo_path, 'w92')}
alt={c.name}
height={14}
maxWidth={80}
/>
) : (
<Building2 size={12} className="text-text-3" />
)}
<span className="truncate">{c.name}</span>
</span>
))}
</div>
</Row>
)}
{networks.length > 0 && (
<Row label={networks.length > 1 ? 'Networks' : 'Network'} alignTop>
<div className="flex flex-col gap-1.5">
{networks.slice(0, 4).map(n => (
<span key={n.id} className="inline-flex items-center gap-2 text-text-1">
{n.logo_path ? (
<BrandLogo
src={getTmdbImageUrl(n.logo_path, 'w92')}
alt={n.name}
height={14}
maxWidth={80}
/>
) : (
<Building2 size={12} className="text-text-3" />
)}
<span className="truncate">{n.name}</span>
</span>
))}
</div>
</Row>
)}
{homepage && (
<Row label="Homepage">
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover transition truncate inline-block max-w-full"
>
{homepage.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
</Row>
)}
</dl>
</div>
)
}
function Row({
label,
children,
alignTop = false,
}: {
label: string
children: React.ReactNode
alignTop?: boolean
}) {
return (
<div className={`flex gap-4 px-5 py-3 ${alignTop ? 'items-start' : 'items-center'}`}>
<dt className="w-32 shrink-0 text-[11.5px] font-semibold text-text-3 uppercase tracking-[0.12em]">
{label}
</dt>
<dd className="flex-1 min-w-0 text-[13.5px] tracking-tight">{children}</dd>
</div>
)
}
function DateLine({ icon, label, date }: { icon: React.ReactNode; label: string; date: string }) {
return (
<span className="inline-flex items-center gap-2 tabular-nums">
<span className="w-4 h-4 grid place-items-center text-text-3">{icon}</span>
<span className="text-[10.5px] text-text-3 uppercase tracking-[0.14em] w-[78px]">{label}</span>
<span>{formatLongDate(date)}</span>
</span>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { useFillerFlag } from '../../lib/episode-meta-context'
interface Props {
season: number | null | undefined
episode: number | null | undefined
}
/**
* Filler/canon chip for anime episodes. Renders nothing when the show
* isn't in our bundled filler list, so non-anime users never see it.
*
* - 'filler': bright orange chip
* - 'mostly-filler': dimmer amber chip
* - 'canon': no chip (canon is the assumed default)
*/
export default function FillerChip({ season, episode }: Props) {
const flag = useFillerFlag(season, episode)
if (!flag || flag === 'canon') return null
if (flag === 'filler') {
return (
<span
className="inline-flex items-center h-4 px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] text-orange-300 bg-orange-500/15 ring-1 ring-orange-500/25"
title="Filler episode (community classification)"
>
Filler
</span>
)
}
return (
<span
className="inline-flex items-center h-4 px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] text-amber-300 bg-amber-500/15 ring-1 ring-amber-500/25"
title="Mostly filler episode (community classification)"
>
Mostly filler
</span>
)
}
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
import L from 'leaflet'
import { parseCoords, type WikidataLocation } from '../../api/wikidata'
interface Props {
locations: WikidataLocation[] | null | undefined
}
// Inline SVG icon - default Leaflet markers reference image files we
// don't ship; building our own keeps the bundle clean and matches the
// app's accent color.
const markerIcon = L.divIcon({
className: 'jf-filming-marker',
html: `<svg width="22" height="30" viewBox="0 0 22 30" xmlns="http://www.w3.org/2000/svg">
<path d="M11 0C4.92 0 0 4.7 0 10.5 0 18 11 30 11 30s11-12 11-19.5C22 4.7 17.08 0 11 0z" fill="#F5B642" stroke="#1d1208" stroke-width="1.4"/>
<circle cx="11" cy="10.5" r="3.5" fill="#1d1208"/>
</svg>`,
iconSize: [22, 30],
iconAnchor: [11, 30],
popupAnchor: [0, -28],
})
interface Pin {
loc: WikidataLocation
lat: number
lon: number
}
function FitToPins({ pins }: { pins: Pin[] }) {
const map = useMap()
useEffect(() => {
if (pins.length === 0) return
if (pins.length === 1) {
map.setView([pins[0].lat, pins[0].lon], 6)
return
}
const bounds = L.latLngBounds(pins.map(p => [p.lat, p.lon] as [number, number]))
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 8 })
}, [pins, map])
return null
}
/**
* Leaflet map plotting filming locations from Wikidata. Shows a small
* legend listing locations missing coordinates so users can still see
* them. Renders nothing when no locations are returned.
*
* The map uses CARTO's dark basemap to match the app's overall
* aesthetic. No API key required.
*/
export default function FilmingLocationsMap({ locations }: Props) {
const [mounted, setMounted] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Defer mount until the user scrolls the map into view. Leaflet creates
// a lot of DOM and tiles on construction; lazy-mount keeps cold detail
// page navigation snappy.
useEffect(() => {
if (!containerRef.current) return
const el = containerRef.current
const obs = new IntersectionObserver(entries => {
for (const e of entries) {
if (e.isIntersecting) {
setMounted(true)
obs.disconnect()
return
}
}
}, { rootMargin: '120px' })
obs.observe(el)
return () => obs.disconnect()
}, [])
const { pins, missing } = useMemo(() => {
const pinned: Pin[] = []
const without: WikidataLocation[] = []
for (const loc of locations || []) {
const c = parseCoords(loc.coords)
if (c) pinned.push({ loc, lat: c[0], lon: c[1] })
else without.push(loc)
}
return { pins: pinned, missing: without }
}, [locations])
if (!locations || locations.length === 0) return null
return (
<div ref={containerRef} className="space-y-3">
<div className="relative aspect-[16/9] rounded-xl overflow-hidden ring-1 ring-border bg-elevated">
{mounted && pins.length > 0 ? (
<MapContainer
center={[pins[0].lat, pins[0].lon]}
zoom={4}
scrollWheelZoom={false}
className="w-full h-full"
attributionControl={false}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; OpenStreetMap, &copy; CARTO'
/>
{pins.map(p => (
<Marker key={p.loc.id} position={[p.lat, p.lon]} icon={markerIcon}>
<Popup>
<div className="text-[12px]">
<p className="font-semibold">{p.loc.label}</p>
{p.loc.countryLabel && (
<p className="text-[11px] opacity-70">{p.loc.countryLabel}</p>
)}
</div>
</Popup>
</Marker>
))}
<FitToPins pins={pins} />
</MapContainer>
) : (
<div className="absolute inset-0 grid place-items-center text-text-4 text-[12px]">
{pins.length === 0 ? 'No mappable coordinates' : 'Loading map...'}
</div>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{locations.map(loc => (
<span
key={loc.id}
className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-elevated/60 ring-1 ring-border text-[11px] text-text-2 tracking-tight"
title={loc.countryLabel || ''}
>
<svg className="w-2.5 h-2.5 text-accent" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 0a4 4 0 00-4 4c0 3 4 8 4 8s4-5 4-8a4 4 0 00-4-4zm0 5.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3z" />
</svg>
{loc.label}
{loc.countryLabel && (
<span className="text-text-4">· {loc.countryLabel}</span>
)}
</span>
))}
</div>
{missing.length > 0 && pins.length > 0 && (
<p className="text-[10.5px] text-text-5 tracking-tight">
{missing.length} {missing.length === 1 ? 'location has' : 'locations have'} no mapped coordinates yet.
</p>
)}
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useLibraryItems } from '../../hooks/use-jellyfin'
import { usePosterGridClasses } from '../../lib/density'
import PosterCard from '../ui/PosterCard'
interface Props {
itemId: string
title: string
collectionType?: string
}
export default function FolderView({ itemId, title, collectionType }: Props) {
const navigate = useNavigate()
let includeItemTypes: string[] | undefined
if (collectionType === 'tvshows') includeItemTypes = ['Series']
else if (collectionType === 'movies') includeItemTypes = ['Movie']
else if (collectionType === 'music') includeItemTypes = ['MusicAlbum', 'MusicArtist']
const { data, isLoading } = useLibraryItems(itemId, {
sortBy: ['SortName'],
sortOrder: ['Ascending'],
includeItemTypes,
limit: 200,
})
const items = data?.Items || []
const gridCls = usePosterGridClasses()
return (
<div className="py-8 px-7">
<h1 className="text-2xl font-semibold text-text-1 mb-6 tracking-tight font-display">{title}</h1>
{isLoading ? (
<PosterGridSkeleton />
) : items.length === 0 ? (
<p className="text-[13px] text-text-4">No items found.</p>
) : (
<div className={gridCls}>
{items.map((item, i) => (
<motion.div
key={item.Id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3) }}
>
<PosterCard item={item} priority={i < 12} onClick={() => item.Id && navigate(`/item/${item.Id}`)} />
</motion.div>
))}
</div>
)}
</div>
)
}
function PosterGridSkeleton() {
const gridCls = usePosterGridClasses()
return (
<div className={gridCls}>
{Array.from({ length: 18 }).map((_, i) => (
<div key={i}>
<div className="skeleton aspect-[2/3] rounded-lg" />
<div className="mt-2 space-y-1">
<div className="skeleton h-3 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useMemo } from 'react'
import { useTmdbPerson } from '../../hooks/use-tmdb'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import ContentRow from '../ui/ContentRow'
import type { TmdbCombinedCreditCrew, TmdbCombinedCreditCast } from '../../api/tmdb'
type Role = 'director' | 'writer' | 'composer' | 'actor'
interface Props {
personId: number
personName: string
role: Role
/** TMDB id of the current item so it gets filtered out of the row. */
excludeTmdbId?: number | string | null
/** Filter the credit list to the same media kind as the current page. */
kind: 'movie' | 'tv'
libraryMap?: Map<string, { id: string; name: string; type: string }> | undefined
}
const ROLE_LABELS: Record<Role, string> = {
director: 'Director',
writer: 'Writer',
composer: 'Composer',
actor: 'Top-billed cast',
}
/**
* Row of other titles by a named person (director, writer, composer, or
* actor). Hides itself when there's too little material to be useful -
* one or two credits feels like a leak, four-plus reads as intentional.
*
* Filters to the same media kind (movie or tv) as the current detail
* page so a series page doesn't get drowned in director's movie work.
*/
export default function FromSameRow({
personId,
personName,
role,
excludeTmdbId,
kind,
libraryMap,
}: Props) {
const personQuery = useTmdbPerson(personId)
const items = useMemo(() => {
const credits = personQuery.data?.combined_credits
if (!credits) return []
const excludeStr = excludeTmdbId != null ? String(excludeTmdbId) : null
const source = role === 'actor' ? credits.cast : credits.crew
const matched = (source as Array<TmdbCombinedCreditCast | TmdbCombinedCreditCrew>).filter(c => {
if (c.media_type !== kind) return false
if (excludeStr && String(c.id) === excludeStr) return false
if (!c.poster_path) return false
if (role === 'actor') return true
const crew = c as TmdbCombinedCreditCrew
if (role === 'director') return crew.job === 'Director'
if (role === 'writer') return crew.department === 'Writing'
if (role === 'composer') {
return (
crew.job === 'Original Music Composer' ||
crew.job === 'Music' ||
crew.job === 'Composer'
)
}
return false
})
// De-dup by tmdb id (writers especially get credited multiple times
// on the same title under different jobs).
const seen = new Set<string>()
const deduped = matched.filter(c => {
const k = String(c.id)
if (seen.has(k)) return false
seen.add(k)
return true
})
return deduped
.sort((a, b) => (b.vote_average || 0) * (b.popularity || 0) - (a.vote_average || 0) * (a.popularity || 0))
.slice(0, 20)
}, [personQuery.data, role, excludeTmdbId, kind])
// Skip rows that would look anemic - fewer than 3 entries doesn't
// earn its keep on the page.
if (items.length < 3) return null
const mapped = mapTmdbToJf(
items.map(it => ({
...it,
adult: false,
// mapTmdbToJf reads media_type to pick kind, which is already set.
})),
libraryMap,
)
const title = role === 'actor' ? `More with ${personName}` : `From ${personName}`
const subtitle = `${ROLE_LABELS[role]} on this title - other ${kind === 'movie' ? 'films' : 'shows'} they've worked on`
return (
<ContentRow
title={title}
subtitle={subtitle}
items={mapped}
layoutKey={`from_same_${role}_${personId}`}
/>
)
}
+279
View File
@@ -0,0 +1,279 @@
import type { BaseItemDto } from '../../api/types'
import MediaTechIcons from '../ui/MediaTechIcons'
import { RefreshCw, Loader2 } from '../../lib/icons'
import {
pickPrimarySource,
getAudioStreams,
resolutionLabel,
videoRangeLabel,
} from '../../lib/jellyfin-meta'
interface Props {
techItem?: BaseItemDto | null
tmdbVote?: number | null
tmdbVoteCount?: number | null
jellyfinCommunity?: number | null
jellyfinCritic?: number | null
/** IMDB rating from the Cinemeta keyless feed (string in the source). */
imdbRating?: number | null
showTmdbRatings?: boolean
/** TV-show specific extras */
seasonsCount?: number | null
episodesCount?: number | null
seriesStatus?: string | null
/** Click handler for the "Rescan files" button shown when the
* techItem has a file but no probed streams. Omit to hide. */
onRescan?: () => void
isRescanning?: boolean
}
/**
* Returns true when the techItem has at least one renderable media
* detail. Mirrors the inner gate of MediaTechIcons so the parent can
* decide whether to show the "Media" label at all rather than render
* an empty section.
*/
function hasMediaSignal(item: BaseItemDto | null | undefined): boolean {
if (!item) return false
const source = pickPrimarySource(item)
const audio = getAudioStreams(item)[0]
const res = resolutionLabel(item)
const range = videoRangeLabel(item)
return !!(res || range || audio?.Codec || source?.Container)
}
/**
* Right-side rail of the detail-page hero. Minimal, frameless. Tech icons
* sit on top, then ratings, then at-a-glance stats - separated by hairline
* dividers rather than card chrome.
*/
export default function HeroTechCard({
techItem,
tmdbVote,
tmdbVoteCount,
jellyfinCommunity,
jellyfinCritic,
imdbRating,
showTmdbRatings = true,
seasonsCount,
episodesCount,
seriesStatus,
onRescan,
isRescanning,
}: Props) {
const showIcons = hasMediaSignal(techItem)
// Rescan is a passive offer whenever a refresh handler is available -
// not only when probe data is missing. Users want a way to re-probe
// even when SOMETHING is shown, because the sample might be from one
// good episode while others in the series are still missing data.
const showRescan = !!onRescan
const isProbeMissing = !showIcons
const showMediaSection = showIcons || showRescan
const showTmdb = showTmdbRatings && tmdbVote != null && tmdbVote > 0
const showJfCommunity = jellyfinCommunity != null && jellyfinCommunity > 0
const showCritic = jellyfinCritic != null && jellyfinCritic > 0
const showImdb = imdbRating != null && imdbRating > 0
const showRatings = showTmdb || showJfCommunity || showCritic || showImdb
const showStats = seasonsCount != null || episodesCount != null || seriesStatus
if (!showMediaSection && !showRatings && !showStats) return null
return (
<aside className="hidden lg:flex flex-col gap-5 w-[260px] shrink-0 self-end pb-1">
{showMediaSection && (
<div>
<div className="flex items-center justify-between gap-2 mb-2.5">
<Label>Media</Label>
{showRescan && (
<button
onClick={onRescan}
disabled={isRescanning}
title={isProbeMissing
? 'Stream info missing - click to ask Jellyfin to re-probe the file'
: 'Ask Jellyfin to re-probe the file (refreshes stream info)'}
aria-label="Rescan media files"
className="inline-flex items-center justify-center w-6 h-6 rounded text-text-3 hover:text-accent hover:bg-elevated/60 transition-colors duration-150 focus-ring disabled:opacity-60 disabled:cursor-progress"
>
{isRescanning ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={11} stroke={2} />
)}
</button>
)}
</div>
{showIcons ? (
<MediaTechIcons item={techItem} className="!gap-2" />
) : (
<button
onClick={onRescan}
disabled={isRescanning}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-[11.5px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-colors duration-150 focus-ring disabled:opacity-60 disabled:cursor-progress"
>
{isRescanning ? (
<>
<Loader2 size={12} className="animate-spin" />
Rescanning...
</>
) : (
<>
<RefreshCw size={12} stroke={2} />
Rescan files
</>
)}
</button>
)}
{!showIcons && (
<p className="text-[10.5px] text-text-4 mt-2 leading-snug">
Stream info missing. Click to ask Jellyfin to re-probe the file.
</p>
)}
</div>
)}
{showMediaSection && (showRatings || showStats) && <Divider />}
{showRatings && (
<div className="space-y-1.5">
<Label>Ratings</Label>
{showTmdb && (
<RatingRow
source="TMDB"
tone="amber"
value={tmdbVote!.toFixed(1)}
meta={tmdbVoteCount ? `${formatVotes(tmdbVoteCount)} votes` : undefined}
tooltip="The Movie Database community score"
/>
)}
{showImdb && (
<RatingRow
source="IMDB"
tone="yellow"
value={imdbRating!.toFixed(1)}
tooltip="IMDB rating (via Cinemeta)"
/>
)}
{showJfCommunity && (
<RatingRow
source="Jellyfin"
tone="cool"
value={jellyfinCommunity!.toFixed(1)}
tooltip="Jellyfin community average"
/>
)}
{showCritic && (
<RatingRow
source="Critics"
tone="emerald"
value={`${Math.round(jellyfinCritic!)}%`}
tooltip="Aggregate critic score"
/>
)}
</div>
)}
{showRatings && showStats && <Divider />}
{showStats && (
<div>
<Label>At a glance</Label>
<dl className="grid grid-cols-3 gap-x-4 gap-y-3 mt-2">
{seasonsCount != null && <Stat label="Seasons" value={seasonsCount} />}
{episodesCount != null && <Stat label="Episodes" value={episodesCount} />}
{seriesStatus && (
<Stat
label="Status"
value={seriesStatus === 'Returning Series' ? 'Airing' : seriesStatus}
tone={
seriesStatus === 'Returning Series' ? 'success'
: seriesStatus === 'Canceled' ? 'error'
: 'default'
}
/>
)}
</dl>
</div>
)}
</aside>
)
}
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2.5 leading-none">
{children}
</p>
)
}
function Divider() {
return <span className="h-px bg-border" />
}
function RatingRow({
source,
tone,
value,
meta,
tooltip,
}: {
source: string
tone: 'amber' | 'cool' | 'emerald' | 'yellow'
value: string
meta?: string
tooltip: string
}) {
const dotCls =
tone === 'amber' ? 'bg-accent'
: tone === 'cool' ? 'bg-cool'
: tone === 'yellow' ? 'bg-yellow-400'
: 'bg-emerald-400'
const valCls =
tone === 'amber' ? 'text-accent'
: tone === 'cool' ? 'text-cool'
: tone === 'yellow' ? 'text-yellow-300'
: 'text-emerald-300'
return (
<div className="flex items-baseline gap-2.5" title={tooltip}>
<span className={`w-1.5 h-1.5 rounded-full self-center shrink-0 ${dotCls}`} />
<span className="text-[12.5px] text-text-2 font-medium">{source}</span>
{meta && <span className="text-[10.5px] text-text-4 truncate">{meta}</span>}
<span className={`ml-auto text-[18px] font-bold tabular-nums tracking-tight font-display ${valCls}`}>
{value}
</span>
</div>
)
}
function Stat({
label,
value,
tone = 'default',
}: {
label: string
value: string | number
tone?: 'default' | 'success' | 'error'
}) {
const valCls =
tone === 'success' ? 'text-success'
: tone === 'error' ? 'text-error'
: 'text-text-1'
return (
<div className="min-w-0">
<p className="text-[9.5px] uppercase tracking-[0.14em] font-semibold text-text-3 mb-0.5 leading-none truncate">
{label}
</p>
<p className={`text-[18px] font-bold tabular-nums tracking-tight font-display truncate ${valCls}`}>
{value}
</p>
</div>
)
}
function formatVotes(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
return n.toString()
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Star, Check } from '../../lib/icons'
import { usePersonalData } from '../../stores/personal-data-store'
interface Props {
itemId: string
/** When true, surfaces a "rewatch?" toggle next to the rewatch counter
* so the user can manually flip the most-recent watch flag without
* needing to re-play. Useful when they marked it watched outside the
* player. */
showRewatchToggle?: boolean
}
/**
* "Personal" section on item detail pages: a 1-10 rating, freeform note,
* and the rewatch tally. All state lives in `usePersonalData` (local).
*
* The note input is debounced via local React state so typing doesn't
* trigger a store write per keystroke.
*/
export default function PersonalSection({ itemId, showRewatchToggle }: Props) {
const entry = usePersonalData(s => s.entries[itemId])
const setRating = usePersonalData(s => s.setRating)
const setNote = usePersonalData(s => s.setNote)
const recordWatch = usePersonalData(s => s.recordWatch)
// Local mirror of the note so typing isn't laggy.
const [localNote, setLocalNote] = useState(entry?.note || '')
// Sync from store on item change (so navigating to another item swaps
// notes correctly without leaking state).
useEffect(() => {
setLocalNote(entry?.note || '')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
// Debounce writes by 400ms.
useEffect(() => {
if (localNote === (entry?.note || '')) return
const id = setTimeout(() => setNote(itemId, localNote), 400)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localNote])
const rating = entry?.rating ?? 0
const rewatchCount = entry?.rewatchCount ?? 0
const lastWasRewatch = entry?.lastWasRewatch ?? false
const [hoverRating, setHoverRating] = useState(0)
// Effective fill is the hover preview when hovering, else the saved rating.
const previewRating = hoverRating || rating
return (
<div className="space-y-5">
<div>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2">
Your rating
</p>
<div
className="flex items-center gap-1.5 flex-wrap"
onMouseLeave={() => setHoverRating(0)}
>
{Array.from({ length: 10 }).map((_, i) => {
const n = i + 1
const on = n <= previewRating
const isHover = hoverRating > 0
return (
<button
key={n}
onClick={() => setRating(itemId, rating === n ? 0 : n)}
onMouseEnter={() => setHoverRating(n)}
aria-label={`Rate ${n} out of 10`}
className={`w-8 h-8 grid place-items-center rounded-md transition-all duration-150 focus-ring ${
on
? isHover
? 'bg-accent/25 text-accent shadow-[0_0_12px_-4px_rgba(245,182,66,0.6)]'
: 'bg-accent/15 text-accent'
: 'bg-elevated/40 text-text-4 hover:text-text-2 hover:bg-elevated/70'
}`}
>
<Star size={14} fill={on ? 'currentColor' : 'none'} stroke={on ? 0 : 1.5} />
</button>
)
})}
<span className="ml-2 text-[12.5px] text-text-3 tabular-nums">
{previewRating > 0 ? `${previewRating} / 10` : 'Not rated'}
</span>
{rating > 0 && (
<button
onClick={() => setRating(itemId, 0)}
className="ml-1 text-[11px] text-text-4 hover:text-text-2 transition focus-ring rounded"
>
Clear
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2">
Note
</p>
<textarea
value={localNote}
onChange={e => setLocalNote(e.target.value)}
placeholder='Sticky note for this item - "Lent to Alex Mar 2026", "Watched at the cinema", etc.'
rows={3}
className="w-full px-3 py-2.5 rounded-md bg-elevated/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[13px] tracking-tight resize-y leading-relaxed text-text-1 placeholder:text-text-4"
/>
</div>
<div>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2">
Rewatches
</p>
<div className="flex items-center gap-3 flex-wrap">
<motion.span
key={rewatchCount}
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent/8 ring-1 ring-accent/20 text-[12.5px] text-accent font-medium tracking-tight"
>
<span className="tabular-nums font-semibold">{rewatchCount}</span>
{rewatchCount === 1 ? 'rewatch' : 'rewatches'}
</motion.span>
{showRewatchToggle && (
<button
onClick={() => recordWatch(itemId, !lastWasRewatch)}
className={`inline-flex items-center gap-1.5 h-8 px-3 rounded-full text-[11.5px] tracking-tight transition focus-ring ${
lastWasRewatch
? 'bg-accent text-void'
: 'bg-elevated/40 ring-1 ring-border text-text-2 hover:text-text-1'
}`}
>
{lastWasRewatch ? <Check size={11} stroke={2.5} /> : null}
{lastWasRewatch ? 'Last watch was a rewatch' : 'Mark last watch as rewatch'}
</button>
)}
</div>
</div>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { motion } from 'framer-motion'
import { Eye, EyeOff, Heart, X, Disc3 } from '../../lib/icons'
import { getBestImage } from '../../api/jellyfin'
import type { BaseItemDto } from '../../api/types'
/**
* Floating ghost that follows the pointer while a drag is in progress.
* Shows the moving item count and a label so multi-row drags read clearly.
*/
export function DragGhost({ count, pointerY, label }: { count: number; pointerY: number; label: string }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12 }}
style={{ position: 'fixed', left: 16, top: pointerY - 18, pointerEvents: 'none', zIndex: 80 }}
className="bg-glass-strong backdrop-blur-xl border border-border-hover rounded-md px-3 py-1.5 shadow-2xl"
>
<p className="text-[12px] text-text-1 font-semibold tracking-tight">
{count > 1 ? `Moving ${count} items` : label || 'Moving item'}
</p>
</motion.div>
)
}
interface SelectionToolbarProps {
count: number
onClear: () => void
onRemove: () => void
onMarkPlayed: () => void
onMarkUnplayed: () => void
onToggleFavorite: () => void
}
/**
* Bottom-floating toolbar that appears whenever the user has rows
* selected. Bundles the bulk actions (mark watched / unwatched, favorite,
* remove) plus a Done button to clear the selection.
*/
export function SelectionToolbar({
count,
onClear,
onRemove,
onMarkPlayed,
onMarkUnplayed,
onToggleFavorite,
}: SelectionToolbarProps) {
return (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="fixed left-1/2 -translate-x-1/2 bottom-6 z-toast inline-flex items-center gap-2 h-12 pl-4 pr-2 rounded-full bg-black/85 backdrop-blur-xl border border-white/12 shadow-2xl"
role="toolbar"
aria-label="Playlist selection"
>
<span className="text-[12.5px] font-semibold text-white tabular-nums tracking-tight">
{count} selected
</span>
<span className="w-px h-6 bg-white/10 mx-1" />
<ToolbarButton onClick={onMarkPlayed} icon={<Eye size={13} />} label="Mark watched" />
<ToolbarButton onClick={onMarkUnplayed} icon={<EyeOff size={13} />} label="Mark unwatched" />
<ToolbarButton onClick={onToggleFavorite} icon={<Heart size={13} />} label="Favorite" />
<ToolbarButton
onClick={onRemove}
icon={<X size={13} />}
label="Remove"
tone="danger"
/>
<span className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={onClear}
className="h-8 px-3 rounded-full text-[11.5px] text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
>
Done
</button>
</motion.div>
)
}
function ToolbarButton({
onClick,
icon,
label,
tone,
}: {
onClick: () => void
icon: React.ReactNode
label: string
tone?: 'danger'
}) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-full text-[11.5px] font-medium transition-colors focus-ring ${
tone === 'danger'
? 'text-error hover:bg-error/15'
: 'text-white/85 hover:text-white hover:bg-white/10'
}`}
title={label}
aria-label={label}
>
{icon}
<span className="hidden lg:inline">{label}</span>
</button>
)
}
/**
* 2x2 poster mosaic fallback for playlists that don't have a primary
* image. Shows the first 4 item posters in a grid - if fewer than 4 are
* available, blanks fill the gaps. Empty state shows a music disc icon.
*/
export function PosterMosaic({ items, serverUrl }: { items: BaseItemDto[]; serverUrl: string }) {
const tiles = items
.slice(0, 4)
.map(it => getBestImage(serverUrl, it, 'primary', 300))
.filter(Boolean) as string[]
return (
<div className="w-[200px] aspect-square rounded-xl overflow-hidden ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)] grid grid-cols-2 grid-rows-2 gap-px bg-elevated">
{tiles.length > 0 ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-elevated relative overflow-hidden">
{tiles[i] ? (
<img src={tiles[i]} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-elevated to-surface" />
)}
</div>
))
) : (
<div className="col-span-2 row-span-2 grid place-items-center">
<Disc3 size={48} className="text-text-4 opacity-50" />
</div>
)}
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Play, Check, Tv2, Film, Music } from '../../lib/icons'
import { getBestImage } from '../../api/jellyfin'
import { formatRuntime } from '../../lib/format'
import type { BaseItemDto } from '../../api/types'
interface Props {
item: BaseItemDto
index: number
serverUrl: string
selected: boolean
dragging: boolean
showDropIndicatorBefore: boolean
onPointerDownBody: (e: React.PointerEvent) => void
refSetter: (el: HTMLLIElement | null) => void
}
export default function PlaylistRow({
item,
index,
serverUrl,
selected,
dragging,
showDropIndicatorBefore,
onPointerDownBody,
refSetter,
}: Props) {
const navigate = useNavigate()
const [thumbErrored, setThumbErrored] = useState(false)
const thumb = getBestImage(serverUrl, item, 'thumb', 320)
const showThumb = thumb && !thumbErrored
const isWatched = item.UserData?.Played
const progress = item.UserData?.PlayedPercentage
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null)
const augmented = item as BaseItemDto & {
LocationType?: string
MediaSources?: unknown[]
Path?: string
AlbumArtist?: string
Album?: string
}
const isMissing =
augmented.LocationType === 'Virtual' ||
(!(augmented.MediaSources?.length) && !augmented.Path)
const subtitle =
item.Type === 'Episode'
? `${item.SeriesName || ''}${
item.ParentIndexNumber != null && item.IndexNumber != null
? ` · S${item.ParentIndexNumber} · E${item.IndexNumber}`
: ''
}`
: item.Type === 'Movie'
? 'Movie'
: item.Type === 'Audio'
? augmented.AlbumArtist || augmented.Album || 'Audio'
: item.Type || ''
const TypeIcon =
item.Type === 'Episode' || item.Type === 'Series'
? Tv2
: item.Type === 'Audio' || item.Type === 'MusicAlbum'
? Music
: Film
function goPlay(e: React.MouseEvent) {
e.stopPropagation()
if (isMissing || !item.Id) return
navigate(`/play/${item.Id}`)
}
return (
<li
ref={refSetter}
className={`relative ${dragging ? 'opacity-30' : ''}`}
>
{showDropIndicatorBefore && (
<span className="absolute -top-px inset-x-2 h-px bg-accent z-10 shadow-[0_0_8px_rgba(245,182,66,0.6)]" />
)}
<div
onPointerDown={onPointerDownBody}
role="button"
tabIndex={0}
aria-label={`Playlist item ${index + 1}`}
aria-selected={selected}
className={`group flex items-center gap-4 px-3 py-2.5 rounded-lg transition-colors duration-150 cursor-pointer focus-ring touch-none ${
isMissing ? 'opacity-60' : ''
} ${
selected
? 'bg-accent/12 ring-1 ring-accent/40 hover:bg-accent/15'
: 'hover:bg-elevated/60'
}`}
>
<span className="w-8 shrink-0 text-[12px] tabular-nums font-mono text-text-4 group-hover:text-text-3 transition-colors text-center">
{String(index + 1).padStart(2, '0')}
</span>
<button
type="button"
onClick={goPlay}
onPointerDown={e => e.stopPropagation()}
disabled={isMissing}
aria-label={isMissing ? 'Item missing' : `Play ${item.Name}`}
className={`relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-elevated ring-1 ring-border focus-ring ${
isMissing ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
{showThumb ? (
<img
src={thumb}
alt=""
onError={() => setThumbErrored(true)}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
<TypeIcon size={20} className="text-text-4 opacity-60" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
{!isMissing && (
<div className="absolute inset-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="w-9 h-9 rounded-full bg-accent grid place-items-center shadow-lg shadow-black/40">
<Play size={13} className="text-void translate-x-0.5" fill="currentColor" />
</div>
</div>
)}
{progress != null && progress > 0 && progress < 95 && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-black/30">
<div className="h-full bg-accent" style={{ width: `${progress}%` }} />
</div>
)}
</button>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2 min-w-0">
<p className="text-[13.5px] text-text-1 font-semibold tracking-tight truncate">
{item.Name || 'Untitled'}
</p>
{isWatched && (
<span
className="shrink-0 w-4 h-4 rounded-full bg-accent grid place-items-center self-center"
title="Watched"
>
<Check size={9} className="text-void" strokeWidth={3} />
</span>
)}
</div>
{subtitle && (
<p className="text-[11.5px] text-text-3 truncate flex items-center gap-1.5">
<TypeIcon size={10} className="text-text-4 shrink-0" />
{subtitle}
</p>
)}
</div>
<div className="hidden md:flex items-center gap-3 text-[11.5px] text-text-4 tabular-nums shrink-0">
{year && <span>{year}</span>}
{year && runtime && <span className="text-text-5">·</span>}
{runtime && <span>{runtime}</span>}
</div>
</div>
</li>
)
}
+555
View File
@@ -0,0 +1,555 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
Play,
Shuffle,
Heart,
Disc3,
} from '../../lib/icons'
import {
usePlaylistItems,
usePlaylistMove,
usePlaylistRemove,
useBulkMarkPlayed,
useBulkToggleFavorite,
} from '../../hooks/use-jellyfin'
import { getBestImage } from '../../api/jellyfin'
import { formatRuntime } from '../../lib/format'
import type { BaseItemDto } from '../../api/types'
import { useQueueStore } from '../../stores/queue-store'
import PlaylistRow from './PlaylistRow'
import { DragGhost, SelectionToolbar, PosterMosaic } from './PlaylistChrome'
/**
* PlaylistItemId is the per-row identifier the Jellyfin Playlists endpoint
* returns so the server knows *which* instance of a duplicated track to
* move or remove. The SDK's BaseItemDto doesn't include this field, but
* the playlist-items response always has it. Extracted here so the cast
* lives in one place.
*/
function playlistItemId(item: BaseItemDto | undefined): string | undefined {
return (item as (BaseItemDto & { PlaylistItemId?: string }) | undefined)?.PlaylistItemId
}
/**
* Editable playlist view. The list supports:
* - Plain click on row body -> /item/{id} (detail)
* - Click on thumbnail -> /play/{id}
* - Ctrl/Cmd-click -> toggle selection (no nav)
* - Shift-click -> range-select from the last anchor
* - Drag a row -> reorder
* - Drag a row in selection -> reorder the whole selection as a unit
* - Selection toolbar -> remove, mark played/unplayed, favorite
*
* Reorders are optimistic: the local copy reflows instantly and Jellyfin's
* moveItem call is fired in the background. On settle, the cached query
* invalidates so the next read confirms server order.
*/
const PRIMARY_BTN =
'inline-flex items-center gap-2 h-11 px-6 rounded-full text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out focus-ring active:scale-[0.97]'
const SECONDARY_BTN =
'inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out focus-ring active:scale-[0.97]'
const ICON_BTN =
'w-11 h-11 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur border border-white/14 hover:border-white/25 transition-all duration-200 ease-out focus-ring active:scale-[0.97] grid place-items-center'
interface Props {
item: BaseItemDto
serverUrl: string
}
export default function PlaylistView({ item, serverUrl }: Props) {
const playlistId = item.Id || ''
const { data: rawItems = [], isLoading } = usePlaylistItems(playlistId || undefined)
const navigate = useNavigate()
const moveMut = usePlaylistMove(playlistId || undefined)
const removeMut = usePlaylistRemove(playlistId || undefined)
const markMut = useBulkMarkPlayed()
const favMut = useBulkToggleFavorite()
// Local optimistic copy. Stays in sync with the server until the user
// does something edit-y, then the local copy leads.
const [localItems, setLocalItems] = useState<BaseItemDto[]>([])
useEffect(() => {
setLocalItems(rawItems as BaseItemDto[])
}, [rawItems])
const items = localItems
/* ── Selection ──────────────────────────────────────────── */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [anchorIndex, setAnchorIndex] = useState<number | null>(null)
// Map of PlaylistItemId -> index for quick lookups
const indexByEntry = useMemo(() => {
const m = new Map<string, number>()
items.forEach((it, i) => {
const eid = playlistItemId(it)
if (eid) m.set(eid, i)
})
return m
}, [items])
// Drop selection if the underlying list lost some entries (e.g. delete)
useEffect(() => {
setSelectedIds(prev => {
const next = new Set<string>()
for (const eid of prev) if (indexByEntry.has(eid)) next.add(eid)
return next.size === prev.size ? prev : next
})
}, [indexByEntry])
// Esc clears selection
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedIds.size > 0) {
e.stopPropagation()
setSelectedIds(new Set())
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selectedIds.size])
function toggleOne(entryId: string, index: number) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(entryId)) next.delete(entryId)
else next.add(entryId)
return next
})
setAnchorIndex(index)
}
function selectRange(toIndex: number) {
if (anchorIndex == null) {
const eid = playlistItemId(items[toIndex])
if (eid) {
setSelectedIds(new Set([eid]))
setAnchorIndex(toIndex)
}
return
}
const lo = Math.min(anchorIndex, toIndex)
const hi = Math.max(anchorIndex, toIndex)
const next = new Set<string>()
for (let i = lo; i <= hi; i++) {
const eid = playlistItemId(items[i])
if (eid) next.add(eid)
}
setSelectedIds(next)
}
function clearSelection() {
setSelectedIds(new Set())
setAnchorIndex(null)
}
/* ── Drag-and-drop ──────────────────────────────────────── */
type DragState = {
pointerId: number
indices: number[] // indices being moved (1 or many for multi-drag)
startY: number
currentY: number
dropIndex: number // 0..items.length, where the drop will land
}
const [drag, setDrag] = useState<DragState | null>(null)
const listRef = useRef<HTMLOListElement | null>(null)
const rowRefs = useRef<Map<number, HTMLLIElement>>(new Map())
// Tracks small movements before commit so a click doesn't accidentally start a drag
const pendingDragRef = useRef<{
pointerId: number
rowIndex: number
startX: number
startY: number
startedDrag: boolean
} | null>(null)
const DRAG_THRESHOLD = 5
function rowMidY(idx: number): number {
const el = rowRefs.current.get(idx)
if (!el) return 0
const r = el.getBoundingClientRect()
return r.top + r.height / 2
}
function computeDropIndex(pointerY: number, draggingIndices: number[]): number {
// Skip the dragged rows themselves when computing drop position so the
// pointer doesn't snap onto a row that's about to vanish.
const draggingSet = new Set(draggingIndices)
for (let i = 0; i < items.length; i++) {
if (draggingSet.has(i)) continue
if (pointerY < rowMidY(i)) return i
}
return items.length
}
function startMaybeDrag(rowIndex: number, e: React.PointerEvent) {
// Only left-button mouse / single-touch, and not on inner buttons
if (e.button !== 0) return
pendingDragRef.current = {
pointerId: e.pointerId,
rowIndex,
startX: e.clientX,
startY: e.clientY,
startedDrag: false,
}
}
function handlePointerMove(e: React.PointerEvent) {
const pending = pendingDragRef.current
if (!pending || pending.pointerId !== e.pointerId) return
if (!pending.startedDrag) {
const dx = e.clientX - pending.startX
const dy = e.clientY - pending.startY
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return
pending.startedDrag = true
// Decide what's being dragged: the row, or the whole selection
const rowEntry = playlistItemId(items[pending.rowIndex])
const draggingSet = new Set<number>()
if (rowEntry && selectedIds.has(rowEntry)) {
for (const eid of selectedIds) {
const idx = indexByEntry.get(eid)
if (idx != null) draggingSet.add(idx)
}
} else {
draggingSet.add(pending.rowIndex)
}
const indices = Array.from(draggingSet).sort((a, b) => a - b)
;(e.target as Element).setPointerCapture?.(e.pointerId)
setDrag({
pointerId: e.pointerId,
indices,
startY: e.clientY,
currentY: e.clientY,
dropIndex: computeDropIndex(e.clientY, indices),
})
return
}
setDrag(prev => {
if (!prev || prev.pointerId !== e.pointerId) return prev
const dropIndex = computeDropIndex(e.clientY, prev.indices)
return { ...prev, currentY: e.clientY, dropIndex }
})
}
function handlePointerUp(e: React.PointerEvent) {
const pending = pendingDragRef.current
pendingDragRef.current = null
if (drag && drag.pointerId === e.pointerId) {
commitDrop(drag)
setDrag(null)
return
}
// No drag started -> treat as click
if (pending && pending.pointerId === e.pointerId && !pending.startedDrag) {
handleRowClick(pending.rowIndex, e)
}
}
function commitDrop(d: DragState) {
const moved = d.indices
if (moved.length === 0) return
const moving = moved.map(i => items[i]).filter(Boolean) as BaseItemDto[]
const remaining = items.filter((_, i) => !moved.includes(i))
// Translate the drop index from "original space" to "remaining-array space"
// by subtracting the count of moved indices that come before the drop
const beforeDrop = moved.filter(i => i < d.dropIndex).length
const insertAt = Math.max(0, Math.min(remaining.length, d.dropIndex - beforeDrop))
const next = [
...remaining.slice(0, insertAt),
...moving,
...remaining.slice(insertAt),
]
setLocalItems(next)
// Fire the API call sequentially so each move sees the server state from
// the previous one. Final indices come from the new array.
;(async () => {
for (let k = 0; k < moving.length; k++) {
const targetIndex = insertAt + k
const movingPlaylistItemId = playlistItemId(moving[k])
if (!movingPlaylistItemId) continue
try {
await moveMut.mutateAsync({ playlistItemId: movingPlaylistItemId, newIndex: targetIndex })
} catch {
/* invalidate-on-settle will reconcile */
}
}
})()
}
function handleRowClick(rowIndex: number, e: React.PointerEvent) {
const it = items[rowIndex]
if (!it) return
const eid = playlistItemId(it)
if (e.shiftKey) {
e.preventDefault()
selectRange(rowIndex)
return
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
if (eid) toggleOne(eid, rowIndex)
return
}
// Plain click - clear selection and navigate
clearSelection()
if (it.Id) navigate(`/item/${it.Id}`)
}
/* ── Bulk actions ───────────────────────────────────────── */
function selectedItemIds(): string[] {
return Array.from(selectedIds)
.map(eid => items[indexByEntry.get(eid) ?? -1]?.Id)
.filter(Boolean) as string[]
}
function bulkRemove() {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setLocalItems(prev => prev.filter(it => !selectedIds.has(playlistItemId(it) ?? '')))
clearSelection()
removeMut.mutate(ids)
}
function bulkMark(played: boolean) {
const ids = selectedItemIds()
if (ids.length === 0) return
markMut.mutate({ itemIds: ids, played })
}
function bulkFavorite() {
const ids = selectedItemIds()
if (ids.length === 0) return
// If any in the selection isn't favorited, favorite them all.
// Otherwise unfavorite them all.
const items_ = ids
.map(id => items.find(it => it.Id === id))
.filter(Boolean) as BaseItemDto[]
const anyMissing = items_.some(it => !it.UserData?.IsFavorite)
favMut.mutate({ itemIds: ids, favorite: anyMissing })
}
/* ── Hero data ──────────────────────────────────────────── */
const totalRuntimeTicks = useMemo(
() => items.reduce((sum, it) => sum + Number(it.RunTimeTicks ?? 0), 0),
[items],
)
const totalRuntime = totalRuntimeTicks > 0 ? formatRuntime(totalRuntimeTicks) : null
const watchedCount = useMemo(
() => items.filter(it => it.UserData?.Played).length,
[items],
)
const playlistPoster = getBestImage(serverUrl, item, 'primary', 600)
const playlistBackdrop =
getBestImage(serverUrl, item, 'backdrop', 1920) ||
(items[0] ? getBestImage(serverUrl, items[0], 'backdrop', 1920) : '') ||
playlistPoster
function playFirst() {
const playable = items.filter(it => !!it.Id) as BaseItemDto[]
if (playable.length === 0) return
useQueueStore.getState().setQueue({
items: playable,
originalOrder: playable,
index: 0,
source: playlistId ? { type: 'playlist', id: playlistId } : null,
shuffled: false,
})
if (playable[0].Id) navigate(`/play/${playable[0].Id}`)
}
function shufflePlay() {
const playable = items.filter(it => !!it.Id) as BaseItemDto[]
if (playable.length === 0) return
// Fisher-Yates - the `.sort(() => Math.random() - 0.5)` trick produces
// biased orderings because TimSort calls the comparator inconsistently.
const shuffled = [...playable]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
useQueueStore.getState().setQueue({
items: shuffled,
originalOrder: playable,
index: 0,
source: playlistId ? { type: 'playlist', id: playlistId } : null,
shuffled: true,
})
if (shuffled[0].Id) navigate(`/play/${shuffled[0].Id}`)
}
function togglePlaylistFavorite() {
if (!item.Id) return
favMut.mutate({ itemIds: [item.Id], favorite: !item.UserData?.IsFavorite })
}
return (
<div onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={() => setDrag(null)}>
{/* ── Hero ─────────────────────────────────────────────── */}
<div className="relative isolate overflow-hidden">
{playlistBackdrop && (
<div className="absolute inset-0 -z-10">
<img
src={playlistBackdrop}
alt=""
className="w-full h-full object-cover scale-110 blur-[6px] opacity-55"
/>
<div className="absolute inset-0 bg-gradient-to-b from-void/30 via-void/70 to-void" />
</div>
)}
<div className="px-7 pt-12 pb-10 flex flex-col md:flex-row gap-7 items-end">
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1] }}
className="shrink-0"
>
{playlistPoster ? (
<img
src={playlistPoster}
alt=""
className="w-[200px] aspect-square object-cover rounded-xl ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)]"
/>
) : (
<PosterMosaic items={items} serverUrl={serverUrl} />
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
className="flex-1 min-w-0"
>
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.16em] text-white/80 mb-3">
<Disc3 size={10} className="text-accent" />
Playlist
</span>
<h1 className="font-display text-4xl md:text-5xl font-bold text-white leading-[1] tracking-tight mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.55)]">
{item.Name || 'Untitled'}
</h1>
<p className="text-[12.5px] text-white/65 font-medium tabular-nums mb-5">
{items.length} {items.length === 1 ? 'item' : 'items'}
{totalRuntime && <> · {totalRuntime}</>}
{watchedCount > 0 && <> · {watchedCount} watched</>}
</p>
<div className="flex items-center gap-2.5 flex-wrap mb-4">
<button
onClick={playFirst}
disabled={items.length === 0}
className={`${PRIMARY_BTN} bg-accent hover:bg-accent-hover text-void shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)] hover:scale-[1.03] disabled:opacity-50 disabled:hover:scale-100`}
>
<Play size={14} className="-ml-0.5" fill="currentColor" />
Play
</button>
<button
onClick={shufflePlay}
disabled={items.length === 0}
className={`${SECONDARY_BTN} disabled:opacity-50`}
>
<Shuffle size={14} stroke={2} />
Shuffle
</button>
<button
onClick={togglePlaylistFavorite}
aria-label="Like playlist"
aria-pressed={!!item.UserData?.IsFavorite}
className={`${ICON_BTN} ${item.UserData?.IsFavorite ? 'text-accent' : 'text-white'}`}
>
<Heart
size={16}
stroke={item.UserData?.IsFavorite ? 0 : 2}
fill={item.UserData?.IsFavorite ? 'currentColor' : 'none'}
/>
</button>
</div>
{item.Overview && (
<p className="text-[13px] text-white/75 leading-[1.7] max-w-[68ch] tracking-[-0.005em]">
{item.Overview}
</p>
)}
</motion.div>
</div>
</div>
{/* ── Item list ────────────────────────────────────────── */}
<div className="px-7 pt-2 pb-10">
{isLoading ? (
<div className="flex flex-col gap-2 mt-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-[68px] rounded-lg skeleton" />
))}
</div>
) : items.length === 0 ? (
<div className="text-center py-16">
<p className="text-[14px] text-text-2 font-medium mb-1">This playlist is empty</p>
<p className="text-[12px] text-text-4">Add items to it from any movie or show on your server.</p>
</div>
) : (
<div className="relative">
{/* Tip strip - shown only when nothing is selected */}
{selectedIds.size === 0 && (
<p className="text-[10.5px] text-text-4 mb-2 mt-1 px-1">
Drag rows to reorder. Ctrl-click to multi-select, Shift-click for a range.
</p>
)}
<ol ref={listRef} className="flex flex-col gap-1 mt-2 select-none">
{items.map((it, i) => {
const eid = playlistItemId(it)
const isSelected = !!eid && selectedIds.has(eid)
const isDragging = drag?.indices.includes(i) || false
return (
<PlaylistRow
key={`${eid || it.Id || i}-${i}`}
item={it}
index={i}
serverUrl={serverUrl}
selected={isSelected}
dragging={isDragging}
showDropIndicatorBefore={drag?.dropIndex === i}
onPointerDownBody={(ev) => startMaybeDrag(i, ev)}
refSetter={(el) => {
if (el) rowRefs.current.set(i, el)
else rowRefs.current.delete(i)
}}
/>
)
})}
{/* Drop indicator at the very end of the list */}
{drag && drag.dropIndex === items.length && (
<li className="relative h-1 -my-px">
<span className="absolute inset-x-2 top-1/2 -translate-y-1/2 h-px bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]" />
</li>
)}
</ol>
{/* Floating drag preview */}
<AnimatePresence>
{drag && (
<DragGhost
count={drag.indices.length}
pointerY={drag.currentY}
label={items[drag.indices[0]]?.Name || ''}
/>
)}
</AnimatePresence>
</div>
)}
</div>
{/* ── Selection toolbar ────────────────────────────────── */}
<AnimatePresence>
{selectedIds.size > 0 && (
<SelectionToolbar
count={selectedIds.size}
onClear={clearSelection}
onRemove={bulkRemove}
onMarkPlayed={() => bulkMark(true)}
onMarkUnplayed={() => bulkMark(false)}
onToggleFavorite={bulkFavorite}
/>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronRight } from '../../lib/icons'
interface Keyword {
id: number
name: string
}
interface Props {
keywords: Keyword[] | null | undefined
/** Plain-text "Production" section pulled from Wikipedia. Optional. */
wikiProduction?: { extract: string; title: string } | null | undefined
}
/**
* Trivia block combining TMDB keywords (always present, lots of them)
* with the Wikipedia "Production" section (richer, sometimes long).
*
* The wiki extract collapses to the first 3 paragraphs by default to keep
* the page calm; a "Read more" toggle reveals the rest.
*/
export default function ProductionTrivia({ keywords, wikiProduction }: Props) {
const [expanded, setExpanded] = useState(false)
const hasKeywords = (keywords?.length ?? 0) > 0
const hasWiki = !!wikiProduction?.extract
if (!hasKeywords && !hasWiki) return null
const fullParagraphs = (wikiProduction?.extract || '')
.split(/\n{2,}/)
.map(p => p.trim())
.filter(p => p.length > 30)
const previewParagraphs = fullParagraphs.slice(0, 3)
const hasMore = fullParagraphs.length > previewParagraphs.length
const shown = expanded ? fullParagraphs : previewParagraphs
return (
<div className="space-y-5">
{hasWiki && shown.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2.5">
Production
</p>
<div className="space-y-3 text-[13.5px] text-text-2 leading-relaxed">
{shown.map((p, i) => (
<motion.p
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.05, 0.3) }}
>
{p}
</motion.p>
))}
</div>
{hasMore && (
<button
onClick={() => setExpanded(v => !v)}
className="mt-3 inline-flex items-center gap-1 text-[11.5px] text-accent hover:text-accent-hover tracking-tight focus-ring rounded"
>
{expanded ? 'Show less' : 'Read more'}
<motion.span animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={11} />
</motion.span>
</button>
)}
<AnimatePresence>
{wikiProduction?.title && (
<motion.p
key="src"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 text-[10.5px] text-text-5 tracking-tight"
>
Source: Wikipedia, "{wikiProduction.title}"
</motion.p>
)}
</AnimatePresence>
</div>
)}
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from '../../lib/icons'
interface Props {
open: boolean
onClose: () => void
title: string
body: string
source?: string | null
}
const FONT_SIZES: Array<{ label: string; class: string }> = [
{ label: 'Aa', class: 'text-[15px] leading-[1.7]' },
{ label: 'Aa', class: 'text-[17px] leading-[1.7]' },
{ label: 'Aa', class: 'text-[19px] leading-[1.75]' },
{ label: 'Aa', class: 'text-[22px] leading-[1.8]' },
]
/**
* Typography-tuned reader overlay for long Wikipedia / Cinemeta
* synopses. Keyboard: Esc to close, +/- to bump font size.
*
* The overlay sits above everything else and traps focus implicitly by
* being modal; we don't render an explicit focus trap because the
* user can dismiss with Esc or click outside.
*/
export default function ReadingMode({ open, onClose, title, body, source }: Props) {
const [fontIdx, setFontIdx] = useState(1)
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
else if (e.key === '+' || e.key === '=') setFontIdx(i => Math.min(FONT_SIZES.length - 1, i + 1))
else if (e.key === '-' || e.key === '_') setFontIdx(i => Math.max(0, i - 1))
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
// Split on blank lines into paragraphs so Wikipedia-style copy reads
// properly, and on single newlines as soft breaks within a paragraph.
const paragraphs = body
.split(/\n{2,}/)
.map(p => p.trim())
.filter(p => p.length > 0)
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[80] bg-void/95 backdrop-blur-sm overflow-y-auto"
onClick={onClose}
>
<motion.article
initial={{ y: 24, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 24, opacity: 0 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="relative max-w-[68ch] mx-auto py-12 px-6 md:px-8"
>
<header className="sticky top-4 z-10 flex items-center justify-end gap-2 mb-4 -mr-2">
<div className="inline-flex items-center bg-elevated/70 ring-1 ring-border rounded-md p-0.5 backdrop-blur">
{FONT_SIZES.map((f, i) => (
<button
key={i}
onClick={() => setFontIdx(i)}
aria-label={`Font size ${i + 1}`}
className={`h-7 px-2 rounded text-text-3 hover:text-text-1 transition ${
fontIdx === i ? 'bg-accent/15 text-accent' : ''
}`}
style={{ fontSize: 10 + i * 2 }}
>
{f.label}
</button>
))}
</div>
<button
onClick={onClose}
aria-label="Close reader"
className="w-8 h-8 grid place-items-center rounded-full bg-elevated/70 ring-1 ring-border text-text-3 hover:text-text-1 backdrop-blur transition focus-ring"
>
<X size={14} stroke={2} />
</button>
</header>
<h1 className="font-display text-3xl md:text-4xl font-bold text-text-1 tracking-tight mb-6 leading-tight [text-wrap:balance]">
{title}
</h1>
<div
className={`space-y-5 text-text-1 ${FONT_SIZES[fontIdx].class} font-light tracking-[-0.005em] [text-wrap:pretty]`}
style={{ fontFamily: 'Geist, system-ui, sans-serif' }}
>
{paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
{source && (
<p className="mt-8 text-[11.5px] text-text-4 tracking-tight">
Source: {source}
</p>
)}
</motion.article>
</motion.div>
)}
</AnimatePresence>
)
}
+253
View File
@@ -0,0 +1,253 @@
import { useMemo } from 'react'
import { Star } from '../../lib/icons'
import type { RTRating } from '../../api/rotten-tomatoes'
import { getPersonalEntry } from '../../stores/personal-data-store'
import { usePersonalData } from '../../stores/personal-data-store'
import { useWikiSection } from '../../hooks/use-external'
interface Props {
/** Jellyfin item id - drives the personal rating lookup. Pass null for
* TMDB-only contexts where personal data doesn't apply. */
itemId?: string | null
rt?: RTRating | null
imdbRating?: number | null
/** TMDB community score on a 0-10 scale. */
tmdbScore?: number | null
tmdbVotes?: number | null
/** Jellyfin's own averaged community rating (item.CommunityRating). */
jellyfinCommunityRating?: number | null
/** Wikipedia article title to fetch the Critical-response / Reception
* section from. If null, the prose summary block is hidden. */
wikiTitle?: string | null
}
interface Rating {
source: string
/** 0-100 normalised score for the aggregate calc. */
norm: number
/** Display value as the user expects it. */
display: string
badge: React.ReactNode
href?: string
}
/**
* Single home for everything users mean when they ask "is it any good?"
* Aggregates RT critics, RT audience, IMDb, TMDB, Jellyfin community,
* and the user's own rating into one panel, computes a meta-average,
* and pairs it with the Wikipedia critical-response paragraph when
* available so the page actually says *why* the score is what it is.
*/
export default function ReceptionPanel({
itemId,
rt,
imdbRating,
tmdbScore,
tmdbVotes,
jellyfinCommunityRating,
wikiTitle,
}: Props) {
// Subscribe so personal rating changes re-render the aggregate live.
usePersonalData(s => s.entries)
const personal = itemId ? getPersonalEntry(itemId) : null
const personalRating = personal && personal.rating > 0 ? personal.rating : null
// Wikipedia "Critical response" section (with "Reception" fallback
// tried inside the same hook - many articles use either heading).
const wikiCritical = useWikiSection(wikiTitle ?? null, 'Critical response')
const wikiReception = useWikiSection(
// Avoid the second fetch when the first hit returns content.
wikiTitle && !wikiCritical.data ? wikiTitle : null,
'Reception',
)
const wikiText = wikiCritical.data?.extract || wikiReception.data?.extract || null
const ratings = useMemo<Rating[]>(() => {
const out: Rating[] = []
if (rt?.criticsScore != null) {
out.push({
source: 'Tomatometer',
norm: rt.criticsScore,
display: `${Math.round(rt.criticsScore)}%`,
href: rt.url,
badge: (
<span className="text-[14px] leading-none">
{rt.criticsRating === 'Rotten' ? '🤢' : '🍅'}
</span>
),
})
}
if (rt?.audienceScore != null) {
out.push({
source: 'Audience',
norm: rt.audienceScore,
display: `${Math.round(rt.audienceScore)}%`,
href: rt.url,
badge: <span className="text-[14px] leading-none">🍿</span>,
})
}
if (typeof imdbRating === 'number' && imdbRating > 0) {
out.push({
source: 'IMDb',
norm: imdbRating * 10,
display: imdbRating.toFixed(1),
badge: (
<span className="inline-flex items-center justify-center px-1 h-3.5 rounded-[2px] bg-[#f5c518] text-[8.5px] font-bold text-black tracking-tight">
IMDb
</span>
),
})
}
if (typeof tmdbScore === 'number' && tmdbScore > 0) {
out.push({
source: tmdbVotes ? `TMDB - ${tmdbVotes.toLocaleString()} votes` : 'TMDB',
norm: tmdbScore * 10,
display: `${Math.round(tmdbScore * 10)}%`,
badge: <Star size={13} className="text-accent" fill="currentColor" stroke={0} />,
})
}
if (typeof jellyfinCommunityRating === 'number' && jellyfinCommunityRating > 0) {
out.push({
source: 'Jellyfin community',
norm: jellyfinCommunityRating * 10,
display: jellyfinCommunityRating.toFixed(1),
badge: <Star size={13} className="text-cool" fill="currentColor" stroke={0} />,
})
}
if (personalRating != null) {
out.push({
source: 'Your rating',
norm: personalRating * 10,
display: `${personalRating}/10`,
badge: <Star size={13} className="text-accent" fill="currentColor" stroke={0} />,
})
}
return out
}, [rt, imdbRating, tmdbScore, tmdbVotes, jellyfinCommunityRating, personalRating])
const aggregate = useMemo(() => {
if (ratings.length === 0) return null
const sum = ratings.reduce((acc, r) => acc + r.norm, 0)
return Math.round(sum / ratings.length)
}, [ratings])
if (ratings.length === 0 && !wikiText) return null
// Trim the wiki prose to the first 2 paragraphs so the panel stays calm.
const wikiParagraphs = wikiText
? wikiText.split(/\n{2,}/).map(s => s.trim()).filter(p => p.length > 40).slice(0, 2)
: []
return (
<div className="rounded-2xl bg-elevated/30 ring-1 ring-border overflow-hidden">
{aggregate != null && (
<div className="px-5 py-4 flex items-center gap-5 border-b border-border/60 bg-void/30">
<div className="shrink-0 relative">
<AggregateGauge value={aggregate} />
</div>
<div className="min-w-0">
<p className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1">
Aggregate
</p>
<p className="text-[12.5px] text-text-2 leading-snug">
Average across {ratings.length} {ratings.length === 1 ? 'source' : 'sources'}.
{ratings.length > 1 && (
<span className="text-text-4"> Equally weighted - the simplest honest math.</span>
)}
</p>
</div>
</div>
)}
{ratings.length > 0 && (
<div className="px-5 py-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-3">
{ratings.map(r => (
<RatingCell key={r.source} rating={r} />
))}
</div>
)}
{wikiParagraphs.length > 0 && (
<div className="border-t border-border/60 px-5 py-4 bg-void/15">
<p className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-3">
Critical response
</p>
<div className="space-y-3 text-[13px] text-text-2 leading-relaxed">
{wikiParagraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
<p className="mt-3 text-[10.5px] text-text-5 tracking-tight">
Source: Wikipedia, "{(wikiCritical.data || wikiReception.data)?.title}"
</p>
</div>
)}
</div>
)
}
function RatingCell({ rating }: { rating: Rating }) {
const content = (
<div className="flex items-center gap-2.5 p-2.5 rounded-lg bg-void/30 ring-1 ring-border/70 hover:ring-border-strong transition-colors h-full">
<span className="shrink-0 w-7 h-7 grid place-items-center">{rating.badge}</span>
<div className="min-w-0 flex-1">
<p className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-3 truncate">
{rating.source}
</p>
<p className="text-[15px] font-display font-bold text-text-1 tabular-nums leading-none mt-0.5">
{rating.display}
</p>
</div>
</div>
)
if (rating.href) {
return (
<a href={rating.href} target="_blank" rel="noopener noreferrer" className="block focus-ring rounded-lg">
{content}
</a>
)
}
return content
}
/**
* Donut-style score gauge. Sweeps a ring proportional to the score and
* tints the stroke amber→cool depending on how high it is. Pure SVG, no
* external dependency.
*/
function AggregateGauge({ value }: { value: number }) {
const clamped = Math.max(0, Math.min(100, value))
const radius = 28
const circumference = 2 * Math.PI * radius
const dash = (clamped / 100) * circumference
const tone =
clamped >= 80 ? 'var(--color-accent)'
: clamped >= 60 ? 'var(--color-accent-deep)'
: clamped >= 40 ? 'var(--color-warning)'
: 'var(--color-error)'
return (
<svg viewBox="0 0 72 72" width={64} height={64} className="-rotate-90">
<circle cx={36} cy={36} r={radius} fill="none" stroke="var(--color-border)" strokeWidth={6} />
<circle
cx={36}
cy={36}
r={radius}
fill="none"
stroke={tone}
strokeWidth={6}
strokeLinecap="round"
strokeDasharray={`${dash} ${circumference}`}
style={{ transition: 'stroke-dasharray 0.6s cubic-bezier(0.16, 1, 0.3, 1)' }}
/>
<text
x="36"
y="40"
textAnchor="middle"
className="font-display font-bold fill-text-1"
style={{ fontSize: 18, transform: 'rotate(90deg)', transformOrigin: '36px 36px' }}
>
{clamped}
</text>
</svg>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Star, ExternalLink } from '../../lib/icons'
import { getTmdbImageUrl, type TmdbReview } from '../../api/tmdb'
import { formatDate } from '../../lib/format'
interface Props {
reviews?: TmdbReview[] | null
}
export default function ReviewsSection({ reviews }: Props) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const list = (reviews || []).slice(0, 4)
if (!list.length) return null
return (
<div className="space-y-3">
{list.map((r, i) => {
const isOpen = expanded[r.id]
const score = r.author_details?.rating
const avatar = r.author_details?.avatar_path
const isShortContent = (r.content?.length || 0) < 320
return (
<motion.article
key={r.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.05, 0.25) }}
className="rounded-xl bg-elevated/40 border border-border p-4 hover:border-border-hover transition-colors"
>
<header className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-full overflow-hidden bg-higher ring-1 ring-border">
{avatar ? (
<img
src={getTmdbImageUrl(normalizeAvatar(avatar), 'w92')}
alt={r.author}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-3 font-display font-semibold text-[13px]">
{(r.author || '?')[0].toUpperCase()}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-[12.5px] text-text-1 font-medium truncate">{r.author}</p>
<p className="text-[10.5px] text-text-4">{formatDate(r.created_at)}</p>
</div>
{score != null && (
<span className="inline-flex items-center gap-1 px-2 h-7 rounded-md bg-accent/15 text-accent text-[12px] font-semibold tabular-nums border border-accent/25">
<Star size={10} fill="currentColor" />
{score.toFixed(1)}
</span>
)}
</header>
<p
className={`text-[12.5px] text-text-2 leading-relaxed whitespace-pre-line ${
!isOpen && !isShortContent ? 'line-clamp-4' : ''
}`}
>
{r.content}
</p>
<div className="flex items-center justify-between mt-2.5">
{!isShortContent && (
<button
onClick={() => setExpanded(s => ({ ...s, [r.id]: !s[r.id] }))}
className="text-[11px] text-accent hover:text-accent-hover transition-colors font-medium focus-ring rounded px-1 -mx-1"
>
{isOpen ? 'Show less' : 'Read full review'}
</button>
)}
{r.url && (
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-[11px] text-text-4 hover:text-text-2 transition-colors flex items-center gap-1"
>
Read on TMDB <ExternalLink size={10} />
</a>
)}
</div>
</motion.article>
)
})}
</div>
)
}
function normalizeAvatar(path: string): string {
// TMDB sometimes returns gravatar-prefixed paths like '/https://...'
if (path.startsWith('/https')) return path.slice(1)
return path
}
@@ -0,0 +1,68 @@
import { useMemo } from 'react'
import type { BaseItemDto } from '../../api/types'
interface Props {
episodes: BaseItemDto[] | null | undefined
/** Hide the sparkline if no episodes have any progress at all (avoids
* visual noise on freshly-added seasons). */
hideWhenEmpty?: boolean
}
/**
* One thin bar per episode. Bar height encodes watched percentage:
* - 0% - tiny stub at the baseline (so the column is countable)
* - <100 - partial accent fill from the bottom
* - 100 - full accent
*
* Renders inline next to the season tab. Cheap (pure DOM, no SVG) and
* tooltips report a "X of N watched - P%" summary.
*/
export default function SeasonProgressSparkline({ episodes, hideWhenEmpty = true }: Props) {
const stats = useMemo(() => {
if (!episodes?.length) return null
let watched = 0
let totalPct = 0
const bars: number[] = []
for (const ep of episodes) {
const ud = ep.UserData
const pct = ud?.PlayedPercentage ?? (ud?.Played ? 100 : 0)
bars.push(pct)
if (ud?.Played) watched++
totalPct += pct
}
const avg = totalPct / episodes.length
return { bars, watched, total: episodes.length, avg }
}, [episodes])
if (!stats) return null
if (hideWhenEmpty && stats.bars.every(b => b === 0)) return null
const tooltip = `${stats.watched} of ${stats.total} watched - ${Math.round(stats.avg)}%`
return (
<span
className="inline-flex items-end gap-[1px] h-3.5 ml-1.5 align-middle"
title={tooltip}
aria-label={tooltip}
>
{stats.bars.map((pct, i) => {
const filled = pct >= 99
const partial = pct > 0 && pct < 99
return (
<span
key={i}
className="w-[2px] rounded-[1px] transition-colors"
style={{
height: pct === 0 ? '2px' : `${Math.max(3, (pct / 100) * 14)}px`,
backgroundColor: filled
? 'var(--accent, #F5B642)'
: partial
? 'rgba(245, 182, 66, 0.5)'
: 'var(--border, rgba(255,255,255,0.12))',
}}
/>
)
})}
</span>
)
}
+282
View File
@@ -0,0 +1,282 @@
import { useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
useSeasons,
useSeasonsEpisodes,
useEpisodes,
} from '../../hooks/use-jellyfin'
import { useTmdbTvShow, useTmdbEpisodeGroup } from '../../hooks/use-tmdb'
import { useCinemeta } from '../../hooks/use-external'
import { buildCinemetaEpisodeMap } from '../../api/cinemeta'
import { EpisodeMetaProvider } from '../../lib/episode-meta-context'
import { parseImdbRating } from '../../lib/episode-meta'
import { useAnimeFiller, classifyEpisode } from '../../lib/anime-filler'
import type { FillerFlag } from '../../lib/anime-filler'
import { applyEpisodeGroupOrder } from '../../lib/episode-order'
import { usePreferencesStore } from '../../stores/preferences-store'
import { formatRuntime } from '../../lib/format'
import { getImageUrl, getStoredServerUrl } from '../../api/jellyfin'
import type { BaseItemDto } from '../../api/types'
import SeasonProgressSparkline from './SeasonProgressSparkline'
import EpisodeOrderToggle from './EpisodeOrderToggle'
import TimeSavedBadge from './TimeSavedBadge'
import EpisodeRow from './EpisodeRow'
import { Section } from '../ui/SectionLabel'
type FillerOf = (
season: number | null | undefined,
episode: number | null | undefined,
) => FillerFlag
interface Props {
seriesId: string
imdbId: string | null
tmdbId: number | null
}
export default function SeriesSection({ seriesId, imdbId, tmdbId }: Props) {
const [selectedSeason, setSelectedSeason] = useState<string | null>(null)
const { data: seasonsRaw } = useSeasons(seriesId)
const showSparklines = usePreferencesStore(s => s.episode.show.sparklines)
// Episode-group ordering. The selected id is either 'aired' (Jellyfin
// sequence, season-scoped) or a TMDB episode-group id (cross-season,
// reordered). Persisted per-series in localStorage so users keep the
// ordering they prefer for each show without polluting global prefs.
const orderStorageKey = `episodeOrder:${seriesId}`
const [orderId, setOrderId] = useState<string>(() => {
if (typeof window === 'undefined') return 'aired'
return localStorage.getItem(orderStorageKey) || 'aired'
})
const handleOrderChange = (id: string) => {
setOrderId(id)
try { localStorage.setItem(orderStorageKey, id) } catch { /* noop */ }
}
const tmdbTv = useTmdbTvShow(tmdbId)
const episodeGroups = tmdbTv.data?.episode_groups?.results || []
const groupQuery = useTmdbEpisodeGroup(orderId !== 'aired' ? orderId : null)
// Fetch Cinemeta for the show so we can pull per-episode IMDB ratings.
// Same call DetailPage already makes - React Query dedups it.
const { data: cinemetaSeriesData } = useCinemeta(imdbId, 'series')
const cinemetaMap = useMemo(
() => buildCinemetaEpisodeMap(cinemetaSeriesData),
[cinemetaSeriesData],
)
// Anime filler classification (only matches when the show is in the
// bundled list, otherwise always returns null).
const fillerData = useAnimeFiller(tmdbId)
// Specials (IndexNumber 0) get pushed to the end so the tabs open on Season 1.
const seasons = useMemo(() => {
if (!seasonsRaw) return seasonsRaw
return [...seasonsRaw].sort((a, b) => {
const ai = a.IndexNumber ?? 0
const bi = b.IndexNumber ?? 0
const aSpecial = ai === 0 ? 1 : 0
const bSpecial = bi === 0 ? 1 : 0
if (aSpecial !== bSpecial) return aSpecial - bSpecial
return ai - bi
})
}, [seasonsRaw])
const firstSeason = seasons?.[0]?.Id
const activeSeason = selectedSeason || firstSeason || null
const serverUrl = getStoredServerUrl()
// Per-season episode fetches feed the progress sparkline on each tab.
// Skipped on huge shows (>20 seasons) where the request burst isn't worth it.
const seasonIds = useMemo(
() => (seasons && seasons.length <= 20 ? seasons.map(s => s.Id!).filter(Boolean) : []),
[seasons],
)
const seasonEpisodeQueries = useSeasonsEpisodes(seriesId, seasonIds)
const episodesBySeason = useMemo(() => {
const m = new Map<string, BaseItemDto[]>()
seasonIds.forEach((id, i) => {
const data = seasonEpisodeQueries[i]?.data
if (data) m.set(id, data)
})
return m
}, [seasonIds, seasonEpisodeQueries])
// When a non-aired group is selected, fetch all episodes across seasons
// so the group can reorder freely. Otherwise stay scoped to the active
// season for performance.
const episodesScope = orderId === 'aired' ? activeSeason || undefined : undefined
const { data: episodes } = useEpisodes(seriesId, episodesScope)
const sortMode = usePreferencesStore(s => s.episode.behavior.sortMode)
const setEpisodeBehavior = usePreferencesStore(s => s.setEpisodeBehavior)
// Map a (season, episode) pair to its absolute episode number, summing
// ChildCount across earlier seasons. Filler lists count this way.
const seasonStarts = useMemo(() => {
const m = new Map<number, number>()
if (!seasons) return m
let running = 0
// Specials (season 0) don't add to the canon count
const ordered = seasons.filter(s => (s.IndexNumber ?? 0) > 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<FillerOf>(() => {
return (season, episode) => {
if (!fillerData || season == null || episode == null) return null
const start = seasonStarts.get(season)
if (start == null) return null // probably specials
return classifyEpisode(start + episode, fillerData)
}
}, [fillerData, seasonStarts])
// Apply group reorder first (if a non-aired group is active), then any
// user sort. When sorting by rating we look up each episode's IMDB
// score in the cinemeta map; episodes without a rating sort to the end
// regardless of direction so unrated entries don't pollute the top.
const orderedEpisodes = useMemo(() => {
if (!episodes) return episodes
const reordered = orderId !== 'aired' && groupQuery.data
? applyEpisodeGroupOrder(episodes, groupQuery.data)
: episodes
if (sortMode === 'order') return reordered
const direction = sortMode === 'rating-desc' ? -1 : 1
return [...reordered].sort((a, b) => {
const ar = parseImdbRating(
cinemetaMap.get(`${a.ParentIndexNumber ?? -1}:${a.IndexNumber ?? -1}`)?.imdbRating,
)
const br = parseImdbRating(
cinemetaMap.get(`${b.ParentIndexNumber ?? -1}:${b.IndexNumber ?? -1}`)?.imdbRating,
)
if (ar == null && br == null) return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)
if (ar == null) return 1
if (br == null) return -1
return (ar - br) * direction
})
}, [episodes, sortMode, cinemetaMap, orderId, groupQuery.data])
return (
<EpisodeMetaProvider cinemetaMap={cinemetaMap} fillerOf={fillerOf}>
<Section label="Episodes">
<div className="flex items-center gap-3 mb-4 -mx-1 px-1">
{orderId === 'aired' && seasons && seasons.length > 1 ? (
<div className="flex-1 flex gap-1.5 overflow-x-auto hide-scrollbar">
{seasons.map(s => {
const isActive = activeSeason === s.Id
return (
<button
key={s.Id}
onClick={() => setSelectedSeason(s.Id!)}
className={`relative flex items-center gap-2 pr-3.5 h-9 text-[12px] font-medium rounded-md transition-all duration-200 whitespace-nowrap focus-ring ${
isActive ? 'text-accent' : 'text-text-3 hover:text-text-1'
}`}
>
{isActive && (
<motion.span
layoutId="season-active"
className="absolute inset-0 bg-accent/15 border border-accent/25 rounded-md"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
{s.ImageTags?.Primary && s.Id && (
<img
src={getImageUrl(serverUrl, s.Id, 'Primary', 64, s.ImageTags.Primary)}
alt=""
className="relative z-10 w-7 h-[42px] rounded-l-md object-cover shrink-0"
loading="lazy"
/>
)}
<span className="relative inline-flex items-center">
{s.Name}
{showSparklines && (
<SeasonProgressSparkline episodes={episodesBySeason.get(s.Id!)} />
)}
</span>
</button>
)
})}
</div>
) : (
<div className="flex-1" />
)}
{episodeGroups.length > 0 && (
<EpisodeOrderToggle
groups={episodeGroups}
selectedId={orderId}
onChange={handleOrderChange}
/>
)}
{cinemetaMap.size > 0 && (
<div className="flex p-0.5 bg-void/60 rounded-md border border-border shrink-0">
{([
{ value: 'order', label: 'Order' },
{ value: 'rating-desc', label: 'Best' },
{ value: 'rating-asc', label: 'Worst' },
] as const).map(opt => {
const active = sortMode === opt.value
return (
<button
key={opt.value}
onClick={() => setEpisodeBehavior('sortMode', opt.value)}
className={`h-7 px-2.5 text-[11px] font-medium tracking-tight rounded transition-colors duration-150 focus-ring ${
active ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
}`}
>
{opt.label}
</button>
)
})}
</div>
)}
</div>
{/* Season totals + time-saved badge */}
<div className="flex items-center gap-3 mb-3 -mx-1 px-1 text-[11.5px] text-text-3">
{(() => {
const list = orderId === 'aired' && activeSeason
? episodesBySeason.get(activeSeason) || episodes || []
: episodes || []
const total = list.reduce((acc, ep) => acc + Number(ep.RunTimeTicks ?? 0), 0)
const count = list.length
if (count === 0) return null
const runtimeLabel = total > 0 ? formatRuntime(total) : null
return (
<span className="tracking-tight">
<span className="tabular-nums text-text-2 font-medium">{count}</span>
<span className="text-text-4"> {count === 1 ? 'episode' : 'episodes'}</span>
{runtimeLabel && (
<>
<span className="text-text-5 mx-1.5">·</span>
<span className="tabular-nums text-text-2 font-medium">{runtimeLabel}</span>
<span className="text-text-4"> total</span>
</>
)}
</span>
)
})()}
<div className="flex-1" />
<TimeSavedBadge seriesId={seriesId} />
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeSeason || 'no-season'}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="space-y-1"
>
{orderedEpisodes?.map((ep, i) => (
<EpisodeRow key={ep.Id} episode={ep} index={i} />
))}
</motion.div>
</AnimatePresence>
</Section>
</EpisodeMetaProvider>
)
}
+267
View File
@@ -0,0 +1,267 @@
import { motion } from 'framer-motion'
import { Calendar, Play, Radio, User, ExternalLink } from '../../lib/icons'
import type { TmdbTvShow, TmdbEpisode } from '../../api/tmdb'
import { getTmdbImageUrl } from '../../api/tmdb'
import type { TvmazeShow } from '../../api/tvmaze'
import { formatDate, formatRelative } from '../../lib/format'
import BrandLogo from '../ui/BrandLogo'
interface Props {
show?: TmdbTvShow | null
tvmazeShow?: TvmazeShow | null
jellyfinAirDays?: string[] | null
jellyfinAirTime?: string | null
}
export default function SeriesStatusBlock({
show,
tvmazeShow,
jellyfinAirDays,
jellyfinAirTime,
}: Props) {
if (!show) return null
const status = show.status
const networks = show.networks || []
const creators = show.created_by || []
const last = show.last_episode_to_air
const next = show.next_episode_to_air
// Schedule fallback chain: Jellyfin's own air metadata > TVmaze schedule.
// TVmaze provides days as e.g. ['Tuesday'] and time as 'HH:mm'.
const tvmazeDays = tvmazeShow?.schedule?.days || []
const tvmazeTime = tvmazeShow?.schedule?.time || ''
const airDays = jellyfinAirDays && jellyfinAirDays.length > 0 ? jellyfinAirDays : tvmazeDays
const airTime = jellyfinAirTime || tvmazeTime
const officialSite = tvmazeShow?.officialSite || null
const tvmazeRuntime = tvmazeShow?.averageRuntime || tvmazeShow?.runtime || null
const hasAny =
!!status ||
networks.length > 0 ||
creators.length > 0 ||
last ||
next ||
(airDays && airDays.length > 0) ||
!!officialSite
if (!hasAny) return null
return (
<div>
{/* ── Frameless stats strip ─────────────────────────────── */}
<div className="flex flex-wrap gap-x-10 gap-y-5 pb-5 mb-5 border-b border-border">
{status && (
<Stat
icon={<Radio size={11} className="text-text-3" strokeWidth={2} />}
label="Status"
>
<StatusValue status={status} />
</Stat>
)}
{networks.length > 0 && (
<Stat
icon={<NetworkGlyph />}
label={networks.length > 1 ? 'Networks' : 'Network'}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 flex-wrap">
{networks.slice(0, 4).map(n => (
n.logo_path ? (
<BrandLogo
key={n.id}
src={getTmdbImageUrl(n.logo_path, 'w92')}
alt={n.name}
height={14}
maxWidth={64}
/>
) : (
<span
key={n.id}
className="text-[11.5px] text-text-1 font-semibold tracking-tight"
title={n.name}
>
{n.name}
</span>
)
))}
</div>
{(airDays?.length || airTime) && (
<span className="inline-flex items-center gap-1.5 text-[11px] text-text-3 -mt-0.5">
<Calendar size={10} className="text-text-4" />
{airDays?.join(', ')}
{airTime ? ` · ${airTime}` : ''}
{tvmazeRuntime ? ` · ${tvmazeRuntime}m` : ''}
</span>
)}
{officialSite && (
<a
href={officialSite}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent-hover transition-colors -mt-0.5 self-start focus-ring rounded"
>
Official site
<ExternalLink size={9} />
</a>
)}
</div>
</Stat>
)}
{creators.length > 0 && (
<Stat
icon={<User size={11} className="text-text-3" strokeWidth={2} />}
label={creators.length > 1 ? 'Created by' : 'Creator'}
>
<span className="text-[13px] text-text-1 font-semibold tracking-tight">
{creators.map(c => c.name).join(', ')}
</span>
</Stat>
)}
</div>
{/* ── Episode features ─────────────────────────────────── */}
{(last || next) && (
<div className={`grid gap-4 ${last && next ? 'md:grid-cols-2' : ''}`}>
{last && <EpisodeFeature ep={last} label="Last episode" tone="muted" />}
{next && <EpisodeFeature ep={next} label="Next episode" tone="accent" />}
</div>
)}
</div>
)
}
function Stat({
icon,
label,
children,
}: {
icon: React.ReactNode
label: string
children: React.ReactNode
}) {
return (
<div className="min-w-0">
<div className="flex items-center gap-1.5 mb-1.5 leading-none">
{icon}
<p className="text-[10px] uppercase tracking-[0.16em] font-semibold text-text-3">
{label}
</p>
</div>
{children}
</div>
)
}
function StatusValue({ status }: { status: string }) {
const tone =
status === 'Returning Series' ? { dot: 'bg-success', text: 'text-success', label: 'Airing' }
: status === 'In Production' ? { dot: 'bg-cool', text: 'text-cool', label: status }
: status === 'Ended' ? { dot: 'bg-text-3', text: 'text-text-1', label: status }
: status === 'Canceled' ? { dot: 'bg-error', text: 'text-error', label: status }
: { dot: 'bg-text-3', text: 'text-text-1', label: status }
return (
<span className="inline-flex items-center gap-2">
<span className={`relative flex h-2 w-2`}>
<span className={`absolute inline-flex h-full w-full rounded-full ${tone.dot}`} />
{tone.label === 'Airing' && (
<span className={`absolute inline-flex h-full w-full rounded-full ${tone.dot} animate-ping opacity-75`} />
)}
</span>
<span className={`text-[13px] font-semibold tracking-tight ${tone.text}`}>{tone.label}</span>
</span>
)
}
function NetworkGlyph() {
// Simple broadcast/wave glyph - matches Tabler icon stroke style
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3" aria-hidden>
<path d="M5 16a8 8 0 0 1 14 0" />
<path d="M8 16a5 5 0 0 1 8 0" />
<circle cx="12" cy="16" r="1" />
</svg>
)
}
function EpisodeFeature({
ep,
label,
tone,
}: {
ep: TmdbEpisode
label: string
tone: 'muted' | 'accent'
}) {
const still = ep.still_path ? getTmdbImageUrl(ep.still_path, 'w500') : ''
const labelCls = tone === 'accent' ? 'text-accent' : 'text-text-3'
return (
<motion.button
type="button"
onClick={() => {/* could navigate to episode if we had a Jellyfin id */}}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="group flex gap-4 p-2.5 rounded-lg hover:bg-glass-light transition-colors text-left focus-ring"
>
<div className="relative w-[180px] aspect-video rounded-md overflow-hidden bg-elevated shrink-0 ring-1 ring-border">
{still ? (
<img
src={still}
alt={ep.name}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="w-full h-full grid place-items-center text-text-4">
<span className="font-display text-2xl font-bold opacity-50">
E{ep.episode_number}
</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/0 to-transparent" />
{tone === 'accent' && (
<div className="absolute inset-0 ring-1 ring-accent/40 rounded-md" />
)}
<div className="absolute bottom-1.5 left-2 text-[10px] font-mono font-bold text-white/95 tabular-nums tracking-wider">
S{ep.season_number} · E{ep.episode_number}
</div>
</div>
<div className="flex-1 min-w-0 py-0.5">
<p className={`text-[10px] uppercase tracking-[0.16em] font-semibold ${labelCls} mb-1.5 leading-none`}>
{label}
</p>
<p className="text-[14.5px] text-text-1 font-semibold tracking-tight leading-tight line-clamp-2">
{ep.name}
</p>
<p className="text-[11.5px] text-text-3 mt-2 flex items-center gap-1.5 tabular-nums">
<Calendar size={10} className="text-text-4" />
{ep.air_date ? formatDate(ep.air_date) : 'TBA'}
{ep.air_date && (
<>
<span className="text-text-5">·</span>
<span className={tone === 'accent' ? 'text-accent' : ''}>
{formatRelative(ep.air_date)}
</span>
</>
)}
</p>
{ep.runtime && (
<p className="text-[11px] text-text-4 mt-1 tabular-nums">{ep.runtime}m</p>
)}
{tone === 'accent' && (
<p className="text-[11px] text-accent font-medium mt-2 inline-flex items-center gap-1">
<Play size={10} fill="currentColor" />
Premieres soon
</p>
)}
</div>
</motion.button>
)
}
+268
View File
@@ -0,0 +1,268 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
ChevronDown,
Film,
Activity,
Bolt,
HardDrive,
Boxes,
Volume2,
Subtitles,
CalendarPlus,
} from '../../lib/icons'
import type { BaseItemDto } from '../../api/types'
import {
pickPrimarySource,
getVideoStream,
getAudioStreams,
getSubtitleStreams,
resolutionLabel,
videoCodecLabel,
framerateLabel,
videoRangeLabel,
audioFormatLabel,
subtitleLabel,
} from '../../lib/jellyfin-meta'
import { formatBitrate, formatBytes, formatDate, languageLabel } from '../../lib/format'
import StatTile, { type StatTileData } from '../ui/StatTile'
import MediaTechIcons from '../ui/MediaTechIcons'
interface Props {
item: BaseItemDto
}
export function hasTechSpecs(item: BaseItemDto): boolean {
const source = pickPrimarySource(item)
const video = getVideoStream(item)
return !!(source || video)
}
export default function TechSpecs({ item }: Props) {
const source = pickPrimarySource(item)
const video = getVideoStream(item)
const audio = getAudioStreams(item)
const subs = getSubtitleStreams(item)
const [open, setOpen] = useState(false)
if (!source && !video) return null
const tiles: StatTileData[] = []
const res = resolutionLabel(item)
const range = videoRangeLabel(item)
if (res) {
tiles.push({
icon: Film,
label: 'Resolution',
value: range ? `${res} · ${range}` : res,
tone: range ? 'amber' : 'default',
})
}
if (video) {
const codec = videoCodecLabel(video)
if (codec) tiles.push({ icon: Film, label: 'Codec', value: codec })
const fps = framerateLabel(video)
if (fps) tiles.push({ icon: Activity, label: 'Frame rate', value: fps })
if (video.BitRate) {
tiles.push({
icon: Bolt,
label: 'Video bitrate',
value: formatBitrate(video.BitRate),
})
}
}
if (audio[0]) {
const a = audioFormatLabel(audio[0])
if (a) tiles.push({ icon: Volume2, label: 'Audio', value: a })
}
if (subs.length) {
tiles.push({
icon: Subtitles,
label: 'Subtitles',
value: `${subs.length} track${subs.length === 1 ? '' : 's'}`,
})
}
if (source?.Container) {
tiles.push({ icon: Boxes, label: 'Container', value: source.Container.toUpperCase() })
}
if (source?.Bitrate) {
tiles.push({ icon: Bolt, label: 'Total bitrate', value: formatBitrate(source.Bitrate) })
}
if (source?.Size) {
tiles.push({ icon: HardDrive, label: 'File size', value: formatBytes(source.Size) })
}
if (item.DateCreated) {
tiles.push({
icon: CalendarPlus,
label: 'Added',
value: formatDate(item.DateCreated),
tone: 'amber',
})
}
return (
<div className="space-y-4">
{/* Top: tech badge strip */}
<MediaTechIcons item={item} />
{/* Tile grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{tiles.map(t => (
<StatTile key={t.label} tile={t} />
))}
</div>
{/* Per-stream details */}
<div className="rounded-lg border border-border overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors focus-ring"
aria-expanded={open}
>
<span className="flex items-center gap-2">
<span className="w-1 h-3.5 rounded-full bg-accent/60" />
Per-stream details
<span className="text-text-4 lowercase tracking-normal font-normal text-[11px]">
({1 + audio.length + subs.length} stream{1 + audio.length + subs.length === 1 ? '' : 's'})
</span>
</span>
<ChevronDown
size={13}
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden border-t border-border"
>
<div className="p-3 space-y-2">
{video && (
<StreamRow
icon={Film}
type="Video"
primary={[
videoCodecLabel(video),
video.Width && video.Height ? `${video.Width}×${video.Height}` : '',
framerateLabel(video),
].filter(Boolean).join(' · ')}
secondary={[
video.BitRate ? formatBitrate(video.BitRate) : '',
video.BitDepth ? `${video.BitDepth}-bit` : '',
video.PixelFormat,
video.ColorSpace?.toUpperCase(),
].filter(Boolean).join(' · ')}
flags={[
range && { label: range, tone: 'amber' as const },
].filter((x): x is { label: string; tone: 'amber' } => !!x)}
/>
)}
{audio.map((a, i) => (
<StreamRow
key={`a-${i}`}
icon={Volume2}
type={`Audio ${audio.length > 1 ? i + 1 : ''}`.trim()}
primary={audioFormatLabel(a) || 'Unknown'}
secondary={[
languageLabel(a.Language),
a.BitRate ? formatBitrate(a.BitRate) : '',
a.SampleRate ? `${(a.SampleRate / 1000).toFixed(1)} kHz` : '',
].filter(Boolean).join(' · ')}
flags={[
a.IsDefault && { label: 'Default', tone: 'accent' as const },
].filter((x): x is { label: string; tone: 'accent' } => !!x)}
/>
))}
{subs.map((s, i) => {
const flags: { label: string; tone: 'cool' | 'accent' | 'success' | 'muted' }[] = []
if (s.IsDefault) flags.push({ label: 'Default', tone: 'accent' })
if (s.IsForced) flags.push({ label: 'Forced', tone: 'cool' })
if (s.IsHearingImpaired) flags.push({ label: 'SDH', tone: 'success' })
if (s.IsExternal) flags.push({ label: 'External', tone: 'muted' })
return (
<StreamRow
key={`s-${i}`}
icon={Subtitles}
type={`Subtitle ${subs.length > 1 ? i + 1 : ''}`.trim()}
primary={subtitleLabel(s).split(' (')[0] || 'Unknown'}
secondary={[
s.Codec?.toUpperCase(),
s.Title && s.Title !== s.DisplayTitle ? s.Title : '',
].filter(Boolean).join(' · ')}
flags={flags}
/>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
function StreamRow({
icon: Icon,
type,
primary,
secondary,
flags,
}: {
icon: typeof Film
type: string
primary: string
secondary?: string
flags?: { label: string; tone: 'amber' | 'cool' | 'accent' | 'success' | 'muted' }[]
}) {
return (
<div className="flex items-start gap-3 p-3 rounded-md bg-elevated/30 border border-border/60">
<span className="shrink-0 w-8 h-8 rounded-md grid place-items-center bg-glass-light text-text-2 ring-1 ring-border">
<Icon size={14} stroke={1.75} />
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-[10px] uppercase tracking-[0.14em] font-semibold text-text-3 leading-none">
{type}
</span>
{flags?.map(f => (
<Flag key={f.label} label={f.label} tone={f.tone} />
))}
</div>
<p className="text-[13px] text-text-1 font-semibold tracking-tight truncate">
{primary}
</p>
{secondary && (
<p className="text-[11.5px] text-text-3 mt-0.5 tabular-nums truncate font-mono">
{secondary}
</p>
)}
</div>
</div>
)
}
function Flag({
label,
tone,
}: {
label: string
tone: 'amber' | 'cool' | 'accent' | 'success' | 'muted'
}) {
const cls =
tone === 'amber' ? 'bg-amber-500/15 text-amber-200 ring-amber-500/25'
: tone === 'cool' ? 'bg-cool/15 text-cool ring-cool/25'
: tone === 'accent' ? 'bg-accent/15 text-accent ring-accent/25'
: tone === 'success' ? 'bg-success/15 text-success ring-success/25'
: 'bg-glass-light text-text-2 ring-border'
return (
<span className={`inline-flex items-center h-4 px-1.5 rounded text-[9px] font-bold uppercase tracking-wider ring-1 leading-none ${cls}`}>
{label}
</span>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { readTimeSaved, formatTimeSaved } from '../../lib/time-saved'
interface Props {
seriesId: string
}
/**
* Small chip surfacing the time auto-skip has saved on this series.
* Renders nothing when the total is below 60 seconds so we don't badge
* shows where the user has only seen one episode.
*/
export default function TimeSavedBadge({ seriesId }: Props) {
const [entry, setEntry] = useState(() => readTimeSaved(seriesId))
useEffect(() => {
setEntry(readTimeSaved(seriesId))
// Re-check when the user comes back to the tab; auto-skip happens in
// a different page, so we want the badge to update on focus.
const onFocus = () => setEntry(readTimeSaved(seriesId))
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [seriesId])
const total = entry.intro + entry.credits
if (total < 60) return null
const introLabel = entry.intro > 0 ? formatTimeSaved(entry.intro) : null
const creditsLabel = entry.credits > 0 ? formatTimeSaved(entry.credits) : null
return (
<span
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md bg-accent/8 ring-1 ring-accent/20 text-[11.5px] text-accent font-medium tracking-tight"
title={[
introLabel ? `Intros: ${introLabel}` : null,
creditsLabel ? `Credits: ${creditsLabel}` : null,
].filter(Boolean).join(' · ')}
>
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm.75 4.25a.75.75 0 00-1.5 0v4c0 .2.08.39.22.53l2.5 2.5a.75.75 0 101.06-1.06l-2.28-2.28V6.25z" />
</svg>
Auto-skip saved {formatTimeSaved(total)}
</span>
)
}
@@ -0,0 +1,56 @@
import { motion } from 'framer-motion'
import { HardDrive } from '../../lib/icons'
import type { BaseItemDto } from '../../api/types'
import { summarizeSource } from '../../lib/jellyfin-meta'
interface Props {
item: BaseItemDto
selectedSourceId: string | null
onChange: (sourceId: string) => void
}
export default function VersionsSelector({ item, selectedSourceId, onChange }: Props) {
const sources = (item.MediaSources || []) as any[]
if (sources.length <= 1) return null
return (
<div className="space-y-2">
<p className="text-[10px] font-semibold text-white/55 uppercase tracking-[0.14em] flex items-center gap-1.5">
<HardDrive size={10} />
Available versions
</p>
<div className="flex gap-2 flex-wrap">
{sources.map(src => {
const summary = summarizeSource(src)
const id = src.Id || src.id || ''
const active = id === selectedSourceId
return (
<button
key={id}
onClick={() => onChange(id)}
className={`relative h-auto min-h-9 px-3 py-1.5 text-left rounded-md border transition-all duration-150 focus-ring ${
active
? 'bg-accent/15 border-accent/40 text-accent'
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10 hover:border-white/20'
}`}
>
{active && (
<motion.span
layoutId="version-active"
className="absolute inset-0 rounded-md ring-1 ring-accent/50"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative block text-[12px] font-semibold tracking-tight">
{summary.label || src.Name || 'Version'}
</span>
{summary.detail && (
<span className="relative block text-[10px] text-white/55 mt-0.5">{summary.detail}</span>
)}
</button>
)
})}
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { Play } from '../../lib/icons'
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
import type { TmdbVideo } from '../../api/tmdb'
interface Props {
videos: TmdbVideo[] | null | undefined
}
const TAB_ORDER: Array<{ key: string; label: string }> = [
{ key: 'Trailer', label: 'Trailers' },
{ key: 'Teaser', label: 'Teasers' },
{ key: 'Featurette', label: 'Featurettes' },
{ key: 'Behind the Scenes', label: 'Behind the scenes' },
{ key: 'Clip', label: 'Clips' },
{ key: 'Bloopers', label: 'Bloopers' },
]
/**
* Tabbed video gallery sourced from TMDB videos.results. We filter to
* YouTube-hosted videos since that's what TMDB overwhelmingly returns
* and what we can thumbnail without an extra API. Tabs only appear for
* categories that actually have videos.
*/
export default function VideosSection({ videos }: Props) {
const buckets = useMemo(() => {
const m = new Map<string, TmdbVideo[]>()
for (const v of videos || []) {
if (v.site !== 'YouTube' || !v.key) continue
const list = m.get(v.type) || []
list.push(v)
m.set(v.type, list)
}
// Stable sort within each bucket: official first, then most recent.
for (const [, list] of m) {
list.sort((a, b) => {
if (a.official !== b.official) return a.official ? -1 : 1
return (b.published_at || '').localeCompare(a.published_at || '')
})
}
return m
}, [videos])
const tabs = TAB_ORDER.filter(t => (buckets.get(t.key)?.length ?? 0) > 0)
const [activeKey, setActiveKey] = useState(() => tabs[0]?.key || 'Trailer')
if (tabs.length === 0) return null
const active = buckets.get(activeKey) || []
return (
<div>
<div className="flex gap-1 mb-4 overflow-x-auto hide-scrollbar -mx-1 px-1">
{tabs.map(t => {
const count = buckets.get(t.key)?.length ?? 0
const on = activeKey === t.key
return (
<button
key={t.key}
onClick={() => setActiveKey(t.key)}
className={`relative h-8 px-3 rounded-md text-[12px] font-medium tracking-tight transition whitespace-nowrap focus-ring ${
on ? 'text-accent' : 'text-text-3 hover:text-text-1'
}`}
>
{on && (
<motion.span
layoutId="videos-active"
className="absolute inset-0 bg-accent/15 border border-accent/25 rounded-md"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative">
{t.label}
<span className={`ml-1.5 tabular-nums ${on ? 'text-accent/70' : 'text-text-4'}`}>{count}</span>
</span>
</button>
)
})}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
{active.map(v => (
<VideoCard key={v.id} video={v} />
))}
</div>
</div>
)
}
function VideoCard({ video }: { video: TmdbVideo }) {
const show = useYoutubeViewer(s => s.show)
const thumb = `https://img.youtube.com/vi/${video.key}/hqdefault.jpg`
return (
<button
type="button"
onClick={() =>
show({
videoKey: video.key,
title: video.name,
subtitle: `${video.type}${video.official ? ' · Official' : ''}`,
})
}
className="group block w-full text-left rounded-xl overflow-hidden bg-elevated ring-1 ring-border hover:ring-border-strong transition focus-ring"
>
<div className="relative aspect-video bg-black overflow-hidden">
<img
src={thumb}
alt={video.name}
loading="lazy"
className="w-full h-full object-cover group-hover:scale-[1.04] transition-transform duration-300"
/>
<div className="absolute inset-0 grid place-items-center bg-black/35 opacity-90 group-hover:bg-black/55 transition">
<span className="w-11 h-11 rounded-full bg-white/95 grid place-items-center text-void shadow-md">
<Play size={16} fill="currentColor" className="translate-x-px" />
</span>
</div>
</div>
<div className="p-3">
<p className="text-[12.5px] font-medium text-text-1 leading-tight tracking-tight line-clamp-2 mb-1">
{video.name}
</p>
<p className="text-[10.5px] text-text-4 uppercase tracking-[0.12em]">
{video.type}{video.official ? ' · Official' : ''}
</p>
</div>
</button>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo } from 'react'
import { motion } from 'framer-motion'
import { Star, RefreshCw, CalendarStar } from '../../lib/icons'
import { useDiary } from '../../stores/diary-store'
interface Props {
itemId?: string | null
}
/**
* Letterboxd-style watch timeline. Reads every diary entry for the
* current item from the diary store, sorts oldest-first so the timeline
* reads chronologically left-to-right, and renders each as a card with
* date / rating / note / rewatch indicator.
*
* Hides itself when there's no diary history - cold-start users
* shouldn't see an empty section staring back at them.
*/
export default function WatchTimeline({ itemId }: Props) {
const entries = useDiary(s => s.entries)
const ordered = useMemo(() => {
if (!itemId) return []
return entries
.filter(e => e.itemId === itemId)
.sort((a, b) => a.watchedAt.localeCompare(b.watchedAt))
}, [entries, itemId])
if (ordered.length === 0) return null
const rewatches = ordered.filter(e => e.rewatch).length
const firstDate = ordered[0].watchedAt
const lastDate = ordered[ordered.length - 1].watchedAt
const spanLabel = ordered.length === 1
? `Watched once - ${formatLong(firstDate)}`
: rewatches > 0
? `${ordered.length} watches over ${spanText(firstDate, lastDate)} - ${rewatches} ${rewatches === 1 ? 'rewatch' : 'rewatches'}`
: `${ordered.length} watches over ${spanText(firstDate, lastDate)}`
return (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<span className="w-7 h-7 grid place-items-center rounded-full bg-accent/15 ring-1 ring-accent/30">
<CalendarStar size={13} className="text-accent" />
</span>
<p className="text-[12.5px] text-text-2 tracking-tight">{spanLabel}</p>
</div>
<div className="relative -mx-1 overflow-x-auto hide-scrollbar">
<ol className="flex items-stretch gap-2 px-1 min-w-max">
{ordered.map((entry, i) => (
<motion.li
key={entry.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }}
className="relative w-[200px] shrink-0 rounded-xl bg-elevated/30 ring-1 ring-border hover:ring-border-strong transition-colors p-3"
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-3 tabular-nums">
{formatShort(entry.watchedAt)}
</span>
{entry.rewatch && (
<span
title="Rewatch"
className="inline-flex items-center gap-0.5 h-4 px-1 rounded-full bg-cool/15 text-cool text-[9.5px] font-semibold uppercase tracking-[0.1em]"
>
<RefreshCw size={9} stroke={2.4} />
Rewatch
</span>
)}
</div>
<div className="flex items-center gap-1.5 mb-1.5">
{entry.emoji && <span className="text-[18px] leading-none">{entry.emoji}</span>}
{typeof entry.rating === 'number' && entry.rating > 0 && (
<span className="inline-flex items-center gap-0.5 text-accent tabular-nums text-[13px] font-display font-bold">
<Star size={12} fill="currentColor" stroke={0} />
{entry.rating}
<span className="text-text-4 text-[10.5px] font-normal font-sans ml-0.5">/10</span>
</span>
)}
</div>
{entry.note ? (
<p className="text-[11.5px] text-text-2 leading-snug line-clamp-3">
{entry.note}
</p>
) : (
<p className="text-[11px] text-text-5 italic">No note</p>
)}
{i < ordered.length - 1 && (
<span
aria-hidden
className="absolute top-1/2 -right-2 w-2 h-px bg-border"
/>
)}
</motion.li>
))}
</ol>
</div>
</div>
)
}
function formatShort(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatLong(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function spanText(firstIso: string, lastIso: string): string {
const a = new Date(firstIso).getTime()
const b = new Date(lastIso).getTime()
const days = Math.max(0, Math.round((b - a) / 86_400_000))
if (days === 0) return 'a single day'
if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'}`
if (days < 60) {
const weeks = Math.round(days / 7)
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'}`
}
if (days < 365) {
const months = Math.round(days / 30)
return `${months} ${months === 1 ? 'month' : 'months'}`
}
const years = (days / 365).toFixed(1).replace(/\.0$/, '')
return `${years} ${years === '1' ? 'year' : 'years'}`
}
+142
View File
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { ExternalLink, Globe } from '../../lib/icons'
import Select, { type SelectOption } from '../ui/Select'
import { motion } from 'framer-motion'
import type { TmdbWatchProviders } from '../../api/tmdb'
import { getTmdbImageUrl } from '../../api/tmdb'
import { countryLabel } from '../../lib/format'
import { SectionLabel } from '../ui/SectionLabel'
interface Props {
providers?: TmdbWatchProviders | null
defaultRegion: string
onRegionChange?: (region: string) => void
}
const COMMON_REGIONS = ['US', 'GB', 'CA', 'AU', 'DE', 'FR', 'ES', 'IT', 'NL', 'JP', 'BR', 'IN', 'MX']
export default function WhereToWatch({ providers, defaultRegion, onRegionChange }: Props) {
const [region, setRegion] = useState(defaultRegion)
const all = providers?.results || {}
const regionData = all[region]
const availableRegions = Object.keys(all).sort()
if (!regionData && !availableRegions.length) return null
function changeRegion(next: string) {
setRegion(next)
onRegionChange?.(next)
}
return (
<Section>
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<SectionLabel>Where to watch</SectionLabel>
<Select
size="sm"
ariaLabel="Region"
triggerIcon={<Globe size={11} stroke={2} />}
value={region}
onChange={changeRegion}
width="min-w-[160px]"
options={
[...new Set([region, ...COMMON_REGIONS, ...availableRegions])].map<SelectOption<string>>(r => ({
value: r,
label: countryLabel(r) || r,
}))
}
/>
</div>
{!regionData ? (
<p className="text-[12px] text-text-4">
Not available for streaming in {countryLabel(region) || region}.
</p>
) : (
<div className="space-y-3">
{regionData.flatrate && (
<ProviderRow label="Stream" link={regionData.link} providers={regionData.flatrate} />
)}
{regionData.free && (
<ProviderRow label="Free" link={regionData.link} providers={regionData.free} />
)}
{regionData.ads && (
<ProviderRow label="With ads" link={regionData.link} providers={regionData.ads} />
)}
{regionData.rent && (
<ProviderRow label="Rent" link={regionData.link} providers={regionData.rent} />
)}
{regionData.buy && (
<ProviderRow label="Buy" link={regionData.link} providers={regionData.buy} />
)}
{regionData.link && (
<a
href={regionData.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-text-4 hover:text-accent transition-colors"
>
View all options on TMDB <ExternalLink size={10} />
</a>
)}
</div>
)}
</Section>
)
}
function ProviderRow({
label,
link,
providers,
}: {
label: string
link: string
providers: { provider_id: number; provider_name: string; logo_path: string | null }[]
}) {
return (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-3 w-16 shrink-0">
{label}
</span>
<div className="flex items-center gap-2 flex-wrap">
{providers.map((p, i) => (
<motion.a
key={p.provider_id}
href={link}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.04 }}
whileHover={{ y: -2 }}
className="group relative shrink-0"
title={p.provider_name}
>
<div className="absolute inset-0 rounded-lg bg-accent/0 group-hover:bg-accent/15 blur-md transition-all duration-200" />
<div className="relative w-10 h-10 rounded-lg overflow-hidden ring-1 ring-border group-hover:ring-accent/40 transition-all duration-150">
{p.logo_path ? (
<img
src={getTmdbImageUrl(p.logo_path, 'w92')}
alt={p.provider_name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full grid place-items-center bg-elevated text-text-3 text-[10px]">
{p.provider_name[0]}
</div>
)}
</div>
</motion.a>
))}
</div>
</div>
)
}
function Section({ children }: { children: React.ReactNode }) {
return <section className="mb-9">{children}</section>
}