Files
jellyfin/web/ItemDetail.tsx
T
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

253 lines
9.6 KiB
TypeScript

import type { JellyItem } from './shared';
import { useEffect, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Check, Heart, Loader2, Play, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import {
backdropUrl,
closeHref,
episodeLabel,
formatBytes,
formatRuntime,
ITEM_PARAM,
itemHref,
posterUrl,
progressPercent,
ticksToSeconds,
} from './shared';
import { useEpisodes, useItemFlags, useJellyfinItem, useSeasons } from './useJellyfinData';
// The detail sheet — an overlay ON the grid, opened by `?item=<id>`.
//
// It is not a route because the grid behind it should stay mounted and scrolled: closing is dropping one
// query parameter, and the back button does it for free. The same component covers a film, a series, a season
// and an episode, because Jellyfin's item shape does — what changes is which extra list is worth showing
// underneath, and that is decided by `Type`.
const META = 'text-xs text-muted-foreground';
const Chip = ({ children }: { children: React.ReactNode }) => (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{children}</span>
);
/** The technical line — what is actually in the file, which is what predicts whether it will transcode. */
const StreamSummary = ({ item }: { item: JellyItem }) => {
const source = item.MediaSources?.[0];
if (!source) return null;
const video = source.MediaStreams?.find((s) => s.Type === 'Video');
const audio = source.MediaStreams?.find((s) => s.Type === 'Audio');
const parts = [
source.Container?.toUpperCase(),
video?.Height ? `${video.Height}p` : null,
video?.Codec?.toUpperCase(),
audio?.Codec?.toUpperCase(),
audio?.Channels ? `${audio.Channels}ch` : null,
formatBytes(source.Size),
].filter(Boolean);
return <p className={META}>{parts.join(' · ')}</p>;
};
const SeriesEpisodes = ({ series }: { series: JellyItem }) => {
const { data: seasonData } = useSeasons(series.Id);
const seasons = seasonData?.Items ?? [];
const [seasonId, setSeasonId] = useState<string | null>(null);
// Default to the first season only once the list arrives; picking one is then the owner's choice and is
// not overwritten by a refetch.
useEffect(() => {
if (!seasonId && seasons.length) setSeasonId(seasons[0]?.Id ?? null);
}, [seasonId, seasons]);
const { data: episodeData, isLoading } = useEpisodes(series.Id, seasonId);
const episodes = episodeData?.Items ?? [];
if (!seasons.length) return null;
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
{seasons.map((season) => (
<button
key={season.Id}
type="button"
onClick={() => setSeasonId(season.Id)}
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
seasonId === season.Id ? 'bg-primary/10 font-medium text-primary' : 'bg-muted text-muted-foreground'
}`}
>
{season.Name ?? `Season ${season.IndexNumber ?? '?'}`}
</button>
))}
</div>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ul className="flex flex-col divide-y rounded-lg border">
{episodes.map((episode) => (
<EpisodeRow key={episode.Id} episode={episode} />
))}
</ul>
)}
</div>
);
};
const EpisodeRow = ({ episode }: { episode: JellyItem }) => {
const { pathname } = useLocation();
const [params] = useSearchParams();
const progress = progressPercent(episode);
return (
<li className="relative">
<Link to={itemHref(pathname, params, episode.Id, true)} className="flex gap-3 p-3 hover:bg-muted/50">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{episodeLabel(episode)}</p>
{episode.Overview && <p className="line-clamp-2 text-[11px] text-muted-foreground">{episode.Overview}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatRuntime(episode.RunTimeTicks)}</span>
{episode.UserData?.Played && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
</Link>
{progress > 0 && progress < 100 && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" style={{ width: `${progress}%` }} />
)}
</li>
);
};
export const ItemDetail = ({ id }: { id: string }) => {
const { token } = useClient();
const { pathname } = useLocation();
const [params] = useSearchParams();
const { data: item, isLoading } = useJellyfinItem(id);
const { setPlayed, setFavorite } = useItemFlags();
const close = closeHref(pathname, params, ITEM_PARAM);
if (isLoading || !item) {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/95">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
const backdrop = backdropUrl(item, token);
const poster = posterUrl(item, token, 300);
const resumeSeconds = ticksToSeconds(item.UserData?.PlaybackPositionTicks);
const played = item.UserData?.Played === true;
const favorite = item.UserData?.IsFavorite === true;
// A series is not playable itself; its Next Up episode is. Sending the series id to the player would ask
// Jellyfin to negotiate playback for a folder, which fails in a way that reads as a broken video.
const playableId = item.Type === 'Series' ? null : item.Id;
const meta = [
item.ProductionYear ? String(item.ProductionYear) : null,
formatRuntime(item.RunTimeTicks),
item.OfficialRating,
item.CommunityRating ? `★ ${item.CommunityRating.toFixed(1)}` : null,
].filter(Boolean);
return (
<div className="absolute inset-0 z-20 overflow-y-auto bg-background">
<div className="relative">
{backdrop && (
<div className="relative h-48 w-full overflow-hidden sm:h-64">
<img src={backdrop} alt="" className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent" />
</div>
)}
<Link
to={close}
aria-label="Close"
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70"
>
<X className="h-4 w-4" />
</Link>
</div>
<div className={`flex flex-col gap-4 p-4 ${backdrop ? '-mt-16' : ''}`}>
<div className="flex gap-4">
{poster && (
<img
src={poster}
alt=""
className="h-40 w-[107px] shrink-0 rounded-lg object-cover shadow-lg ring-1 ring-black/10"
/>
)}
<div className="flex min-w-0 flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-tight">{item.Name}</h2>
{item.Type === 'Episode' && item.SeriesName && (
<p className={META}>
{item.SeriesName} · {episodeLabel(item)}
</p>
)}
{item.Taglines?.[0] && <p className="text-xs italic text-muted-foreground">{item.Taglines[0]}</p>}
<p className={META}>{meta.join(' · ')}</p>
<div className="flex flex-wrap gap-1">
{item.Genres?.slice(0, 5).map((g) => (
<Chip key={g}>{g}</Chip>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{playableId && (
<Button asChild size="sm">
<Link to={itemHref(pathname, params, playableId, true)}>
<Play className="mr-1.5 h-3.5 w-3.5" fill="currentColor" />
{resumeSeconds > 0 ? 'Resume' : 'Play'}
</Link>
</Button>
)}
<Button
variant="outline"
size="sm"
disabled={setPlayed.isPending}
onClick={() => void setPlayed.mutateAsync({ id: item.Id, played: !played }).catch(() => undefined)}
>
<Check className={`mr-1.5 h-3.5 w-3.5 ${played ? 'text-primary' : ''}`} />
{played ? 'Watched' : 'Mark watched'}
</Button>
<Button
variant="outline"
size="sm"
disabled={setFavorite.isPending}
onClick={() => void setFavorite.mutateAsync({ id: item.Id, favorite: !favorite }).catch(() => undefined)}
>
<Heart className={`mr-1.5 h-3.5 w-3.5 ${favorite ? 'fill-current text-destructive' : ''}`} />
Favourite
</Button>
</div>
{item.Overview && <p className="max-w-3xl text-xs leading-relaxed text-muted-foreground">{item.Overview}</p>}
<StreamSummary item={item} />
{item.Type === 'Series' && <SeriesEpisodes series={item} />}
{!!item.People?.length && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Cast</h3>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{item.People.filter((person) => person.Type === 'Actor')
.slice(0, 12)
.map((person) => (
<p key={`${person.Id}-${person.Role}`} className="text-[11px]">
<span className="font-medium">{person.Name}</span>
{person.Role && <span className="text-muted-foreground"> as {person.Role}</span>}
</p>
))}
</div>
</div>
)}
</div>
</div>
);
};