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.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import type { JellyfinServerRow } from './useJellyfinData';
|
||||
import { useState } from 'react';
|
||||
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatShortDate } from './shared';
|
||||
import {
|
||||
jellyfinErrorMessage,
|
||||
useJellyfinHealth,
|
||||
useJellyfinServerActions,
|
||||
useJellyfinServers,
|
||||
} from './useJellyfinData';
|
||||
|
||||
// Connecting Jellyfin servers to Officer, from the app.
|
||||
//
|
||||
// This screen is BOTH the setup wizard and the permanent settings page: JellyfinView renders it in place of
|
||||
// whatever section the URL asks for while nothing is connected, and /jellyfin/settings renders it for good.
|
||||
// One component, so a second server added later goes through exactly the code path that stored the first.
|
||||
//
|
||||
// There is no "paste an API key" alternative here, unlike the Immich screen. Jellyfin's API keys are
|
||||
// SERVER-wide and identity-less — they authenticate as nobody, which breaks every per-user route this app
|
||||
// depends on (resume points, next up, watched marks). Signing in is the only credential that has a user
|
||||
// attached, so it is the only one offered. The password is used once and never stored.
|
||||
|
||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||
|
||||
/** Jellyfin's own default port. The sidecar dials from this machine, so localhost is the right default. */
|
||||
const DEFAULT_URL = 'http://localhost:8096';
|
||||
|
||||
const URL_HINT =
|
||||
"The server's base URL, without /web. Officer reaches it from the server, not from this browser — so " +
|
||||
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
||||
|
||||
const SIGN_IN_HINT =
|
||||
'Your Jellyfin login. It is used once, by the sidecar, to exchange for an access token — the password is ' +
|
||||
'never stored and never reaches Postgres. The session shows up in Jellyfin under Devices as "Officer"; ' +
|
||||
'signing it out there is what revokes this connection.';
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{hint && <span className={HINT}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
function useCredential() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const filled = !!username.trim() && !!password;
|
||||
const reset = () => {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
return { username, setUsername, password, setPassword, filled, reset };
|
||||
}
|
||||
|
||||
type CredentialState = ReturnType<typeof useCredential>;
|
||||
|
||||
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
||||
<>
|
||||
<Field
|
||||
label="Username"
|
||||
value={cred.username}
|
||||
onChange={cred.setUsername}
|
||||
placeholder="your Jellyfin user"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={cred.password}
|
||||
onChange={cred.setPassword}
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
hint={SIGN_IN_HINT}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* One stored server. The active one carries the live health line, because health only ever describes the
|
||||
* server actually in use — showing a status next to the others would be inventing one. "Test" on an inactive
|
||||
* row probes it directly instead, which is the only way to check a server without switching to it first.
|
||||
*/
|
||||
const ServerRow = ({ server }: { server: JellyfinServerRow }) => {
|
||||
const { data: health, refetch: recheck, isFetching: checking } = useJellyfinHealth();
|
||||
const { edit, activate, remove, test } = useJellyfinServerActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
// Removing a server throws away a token Officer can never show again, so the bin asks once.
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const run = async (action: Promise<unknown>) => {
|
||||
setError(null);
|
||||
try {
|
||||
await action;
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
} catch (err) {
|
||||
setError(jellyfinErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const busy = checking || test.isPending;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${server.isActive ? 'border-primary/40' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{server.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate font-medium">{server.label}</span>
|
||||
{server.isActive && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{!server.isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={activate.isPending}
|
||||
onClick={() => void run(activate.mutateAsync(server.id))}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={busy}
|
||||
onClick={() => void (server.isActive ? recheck() : run(test.mutateAsync(server.id)))}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!confirming) return setConfirming(true);
|
||||
setConfirming(false);
|
||||
void run(remove.mutateAsync(server.id));
|
||||
}}
|
||||
>
|
||||
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="truncate text-muted-foreground">
|
||||
{server.url}
|
||||
{server.jellyfinUsername ? ` · ${server.jellyfinUsername}` : ''}
|
||||
</p>
|
||||
|
||||
{server.isActive ? (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{health?.ok ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||
) : (
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
|
||||
)}
|
||||
<span>
|
||||
{health?.ok
|
||||
? `Jellyfin ${health.version ?? '?'}${health.serverName ? ` · ${health.serverName}` : ''}`
|
||||
: (health?.error ?? 'not checked yet')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{server.version ? `Jellyfin ${server.version}` : 'version unknown'}
|
||||
{server.lastSeenAt ? ` · last answered ${formatShortDate(server.lastSeenAt)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{replacing ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||
<CredentialFields cred={cred} autoFocus />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!cred.filled || edit.isPending}
|
||||
onClick={() =>
|
||||
void run(edit.mutateAsync({ id: server.id, username: cred.username.trim(), password: cred.password }))
|
||||
}
|
||||
>
|
||||
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplacing(true)}
|
||||
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Sign in again
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionSection = () => {
|
||||
const { data, isLoading } = useJellyfinServers();
|
||||
const { add } = useJellyfinServerActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const servers = data?.servers ?? [];
|
||||
const hasServers = servers.length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('The Jellyfin URL is required');
|
||||
if (!cred.filled) return setError('A username and password are required');
|
||||
|
||||
try {
|
||||
await add.mutateAsync({
|
||||
label: label.trim(),
|
||||
url: url.trim(),
|
||||
username: cred.username.trim(),
|
||||
password: cred.password,
|
||||
});
|
||||
setLabel('');
|
||||
setUrl(DEFAULT_URL);
|
||||
cred.reset();
|
||||
} catch (err) {
|
||||
setError(jellyfinErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Plug className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{hasServers ? 'Jellyfin servers' : 'Connect Jellyfin'}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasServers
|
||||
? 'Officer stores each server and its access token encrypted, for this account only. One is in use at a time.'
|
||||
: 'Video needs a Jellyfin server and a login before it can show anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && servers.map((server) => <ServerRow key={server.id} server={server} />)}
|
||||
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<p className="text-xs font-medium">{hasServers ? 'Add another server' : 'Add a server'}</p>
|
||||
<Field
|
||||
label="Label"
|
||||
value={label}
|
||||
onChange={setLabel}
|
||||
placeholder="Optional — defaults to the server's own name"
|
||||
hint="What the switcher calls this server. Two servers cannot share a label; two can share a URL."
|
||||
autoFocus={hasServers}
|
||||
/>
|
||||
<Field
|
||||
label="Jellyfin URL"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder={DEFAULT_URL}
|
||||
hint={URL_HINT}
|
||||
autoFocus={!hasServers}
|
||||
/>
|
||||
<CredentialFields cred={cred} />
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={add.isPending}>
|
||||
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{add.isPending ? 'Verifying…' : hasServers ? 'Add server' : 'Connect'}
|
||||
</Button>
|
||||
{add.isPending && <span className={HINT}>Checking the server and signing in…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { MediaShelf } from './MediaCard';
|
||||
import { useJellyfinHome } from './useJellyfinData';
|
||||
|
||||
// The landing screen: what you were watching, what comes next, and the newest thing in each library.
|
||||
//
|
||||
// One request. The sidecar fans out to Jellyfin's four different endpoints and swallows per-library failures,
|
||||
// so a library mid-scan costs its own shelf rather than the whole page — which is why there is no per-shelf
|
||||
// loading state here to write.
|
||||
|
||||
export const HomeSection = () => {
|
||||
const { data, isLoading, error } = useJellyfinHome();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
||||
The server did not answer.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resume = data?.resume ?? [];
|
||||
const nextUp = data?.nextUp ?? [];
|
||||
const latest = data?.latest ?? [];
|
||||
const empty = !resume.length && !nextUp.length && !latest.some((shelf) => shelf.items.length);
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p className="text-sm font-medium">Nothing here yet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
The server is connected but its video libraries are empty — or still scanning.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<MediaShelf title="Continue watching" items={resume} wide />
|
||||
<MediaShelf title="Next up" items={nextUp} wide />
|
||||
{latest.map((shelf) => (
|
||||
<MediaShelf key={shelf.viewId} title={`Latest in ${shelf.viewName}`} items={shelf.items} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { JellyItem } from './shared';
|
||||
import { Link, NavLink, useSearchParams } from 'react-router';
|
||||
import { Clapperboard, Film, FolderOpen, Home, Images, ListVideo, Plug, Search, Tv } from 'lucide-react';
|
||||
import { jellyfinSectionPath, VIEW_PARAM } from './shared';
|
||||
import { ServerSwitcher } from './ServerSwitcher';
|
||||
import { useJellyfinViews } from './useJellyfinData';
|
||||
|
||||
// Left panel of the /jellyfin workspace: Home and Search, the libraries the server actually has, then the
|
||||
// connection screen.
|
||||
//
|
||||
// The libraries are not a fixed vocabulary, so they cannot be sections in the path — they are
|
||||
// /jellyfin/library?view=<id>. That keeps the section list closed (four entries, checked by a type guard)
|
||||
// while the thing being browsed stays addressable, which is the same split the photos app uses for albums.
|
||||
//
|
||||
// Their active state is computed rather than taken from NavLink, because every library link shares the path
|
||||
// /jellyfin/library and react-router's isActive does not look at the query string.
|
||||
|
||||
const ICON_FOR_COLLECTION: Record<string, typeof Film> = {
|
||||
movies: Film,
|
||||
tvshows: Tv,
|
||||
homevideos: Images,
|
||||
musicvideos: Clapperboard,
|
||||
boxsets: FolderOpen,
|
||||
playlists: ListVideo,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
const ACTIVE = 'bg-primary/10 font-medium text-primary';
|
||||
const IDLE = 'text-muted-foreground hover:bg-muted hover:text-foreground';
|
||||
|
||||
const Marker = () => <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />;
|
||||
|
||||
export const JellyfinNav = () => {
|
||||
const { data } = useJellyfinViews();
|
||||
const [params] = useSearchParams();
|
||||
const activeView = params.get(VIEW_PARAM);
|
||||
|
||||
const views = data?.views ?? [];
|
||||
|
||||
const sectionLink = (id: 'home' | 'search' | 'settings', label: string, Icon: typeof Film) => (
|
||||
<NavLink key={id} to={jellyfinSectionPath(id)} className={({ isActive }) => `${ROW} ${isActive ? ACTIVE : IDLE}`}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <Marker />}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const libraryLink = (view: JellyItem) => {
|
||||
const Icon = ICON_FOR_COLLECTION[view.CollectionType ?? ''] ?? FolderOpen;
|
||||
const isActive = activeView === view.Id;
|
||||
return (
|
||||
<Link
|
||||
key={view.Id}
|
||||
to={`${jellyfinSectionPath('library')}?${VIEW_PARAM}=${encodeURIComponent(view.Id)}`}
|
||||
className={`${ROW} ${isActive ? ACTIVE : IDLE}`}
|
||||
>
|
||||
{isActive && <Marker />}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className="truncate">{view.Name ?? 'Library'}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary ring-1 ring-black/5">
|
||||
<Clapperboard className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Video</div>
|
||||
<ServerSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2">
|
||||
{sectionLink('home', 'Home', Home)}
|
||||
{sectionLink('search', 'Search', Search)}
|
||||
</nav>
|
||||
|
||||
{views.length > 0 && (
|
||||
<>
|
||||
<div className="mx-4 my-2 border-t" />
|
||||
<p className="px-4 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Libraries</p>
|
||||
<nav className="flex flex-col gap-0.5 px-2">{views.map(libraryLink)}</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mx-4 my-2 border-t" />
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">{sectionLink('settings', 'Connection', Plug)}</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { ConnectionSection } from './ConnectionSection';
|
||||
import { HomeSection } from './HomeSection';
|
||||
import { ItemDetail } from './ItemDetail';
|
||||
import { LibrarySection } from './LibrarySection';
|
||||
import { SearchSection } from './SearchSection';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { ITEM_PARAM, jellyfinSectionPath, PLAY_PARAM } from './shared';
|
||||
import { useJellyfinHealth } from './useJellyfinData';
|
||||
import { useJellyfinSection } from './useJellyfinSection';
|
||||
|
||||
// Right panel of the /jellyfin workspace — renders the section named by the URL, with the detail sheet and
|
||||
// the player as overlays on top of it.
|
||||
//
|
||||
// Health gates all of them, and its two failure modes are answered differently: nothing configured yet is the
|
||||
// setup form, whatever section was asked for, because there is nothing else useful to show; a stored server
|
||||
// that is failing keeps its own message and a way back to the connection screen, since replacing a working
|
||||
// sign-in by accident is worse than a wall of text.
|
||||
//
|
||||
// The player wins over the detail sheet when both parameters are set, and `itemHref` never sets both — but
|
||||
// the precedence is written down rather than left to whichever renders last.
|
||||
|
||||
export const JellyfinView = () => {
|
||||
const section = useJellyfinSection();
|
||||
const [params] = useSearchParams();
|
||||
const { data: health, isLoading } = useJellyfinHealth();
|
||||
|
||||
const playId = params.get(PLAY_PARAM);
|
||||
const itemId = params.get(ITEM_PARAM);
|
||||
|
||||
if (section === 'settings') return <ConnectionSection />;
|
||||
|
||||
if (!isLoading && health && !health.ok) {
|
||||
if (health.configured === false) return <ConnectionSection />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p className="text-sm font-medium">Jellyfin is not answering</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{health.error ?? 'The jellyfin sidecar could not reach the configured server.'}
|
||||
</p>
|
||||
<Link to={jellyfinSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||
Check the connection
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const body = section === 'library' ? <LibrarySection /> : section === 'search' ? <SearchSection /> : <HomeSection />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{body}
|
||||
{playId ? <VideoPlayer id={playId} /> : itemId ? <ItemDetail id={itemId} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Clapperboard } from 'lucide-react';
|
||||
import { JELLYFIN_SECTIONS } from './shared';
|
||||
import { useJellyfinSection } from './useJellyfinSection';
|
||||
|
||||
// Panel header for the right (jellyfin-view) panel.
|
||||
|
||||
export const JellyfinViewHeader = () => {
|
||||
const section = useJellyfinSection();
|
||||
const label = JELLYFIN_SECTIONS.find((s) => s.id === section)?.label ?? 'Video';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Clapperboard className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">{label}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { MediaGrid } from './MediaCard';
|
||||
import { VIEW_PARAM } from './shared';
|
||||
import { useJellyfinItems, useJellyfinViews } from './useJellyfinData';
|
||||
|
||||
// One library, as a grid.
|
||||
//
|
||||
// Which library is `?view=<id>`; the sort is `?sort=` and `?order=`. All three are in the URL rather than in
|
||||
// component state so a particular view of a particular library is a link you can keep — and so the nav can
|
||||
// tell which library is open without either panel talking to the other.
|
||||
|
||||
const SORTS = [
|
||||
{ id: 'SortName', label: 'Name' },
|
||||
{ id: 'DateCreated', label: 'Recently added' },
|
||||
{ id: 'PremiereDate', label: 'Release date' },
|
||||
{ id: 'CommunityRating', label: 'Rating' },
|
||||
{ id: 'Random', label: 'Random' },
|
||||
] as const;
|
||||
|
||||
const SORT_PARAM = 'sort';
|
||||
const ORDER_PARAM = 'order';
|
||||
|
||||
// A show is a Series, not its episodes: a tvshows library browsed recursively for Episode would be thousands
|
||||
// of tiles of the same six posters. BoxSets are collapsed for the same reason.
|
||||
const TYPES_FOR_COLLECTION: Record<string, string> = {
|
||||
movies: 'Movie',
|
||||
tvshows: 'Series',
|
||||
homevideos: 'Video,Photo',
|
||||
musicvideos: 'MusicVideo',
|
||||
boxsets: 'BoxSet',
|
||||
playlists: 'Playlist',
|
||||
};
|
||||
|
||||
const CHIP = 'rounded-md px-2 py-1 text-[11px] transition-colors';
|
||||
|
||||
export const LibrarySection = () => {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const viewId = params.get(VIEW_PARAM);
|
||||
const sortBy = params.get(SORT_PARAM) ?? 'SortName';
|
||||
const sortOrder = params.get(ORDER_PARAM) === 'Descending' ? 'Descending' : 'Ascending';
|
||||
|
||||
const { data: viewData } = useJellyfinViews();
|
||||
const view = viewData?.views.find((v) => v.Id === viewId) ?? null;
|
||||
|
||||
const { data, isLoading } = useJellyfinItems(
|
||||
{
|
||||
parentId: viewId ?? undefined,
|
||||
includeItemTypes: TYPES_FOR_COLLECTION[view?.CollectionType ?? ''] ?? undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: 500,
|
||||
collapseBoxSetItems: false,
|
||||
},
|
||||
!!viewId,
|
||||
);
|
||||
|
||||
if (!viewId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
||||
Pick a library on the left.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.Items ?? [];
|
||||
const wide = view?.CollectionType === 'homevideos';
|
||||
|
||||
const setSort = (id: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
// Clicking the sort you are already on flips the direction, which is the behaviour every table has and
|
||||
// saves a second control that would only ever be used with this one.
|
||||
if (id === sortBy) next.set(ORDER_PARAM, sortOrder === 'Ascending' ? 'Descending' : 'Ascending');
|
||||
else next.delete(ORDER_PARAM);
|
||||
next.set(SORT_PARAM, id);
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<h2 className="truncate text-sm font-semibold">{view?.Name ?? 'Library'}</h2>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{data?.TotalRecordCount != null ? `${data.TotalRecordCount.toLocaleString()} items` : ''}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 rounded-lg bg-muted p-0.5">
|
||||
{SORTS.map((sort) => (
|
||||
<button
|
||||
key={sort.id}
|
||||
type="button"
|
||||
onClick={() => setSort(sort.id)}
|
||||
className={`${CHIP} ${sortBy === sort.id ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{sort.label}
|
||||
{sortBy === sort.id && sort.id !== 'Random' ? (sortOrder === 'Ascending' ? ' ↑' : ' ↓') : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<MediaGrid items={items} wide={wide} />
|
||||
) : (
|
||||
<p className="pt-10 text-center text-xs text-muted-foreground">This library is empty.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { JellyItem } from './shared';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||
import { Check, Play } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { episodeLabel, itemHref, posterUrl, progressPercent, subtitleFor, thumbUrl } from './shared';
|
||||
|
||||
// One tile in a shelf or a grid.
|
||||
//
|
||||
// It is a real <Link> to `?item=<id>` — cmd-click opens the detail in a new tab, the URL says what is open,
|
||||
// and the back button closes it. The play button is a SIBLING anchor rather than nested inside, because an
|
||||
// anchor inside an anchor is not valid HTML and browsers resolve it by dropping one of them.
|
||||
|
||||
type MediaCardProps = { item: JellyItem; wide?: boolean };
|
||||
|
||||
export const MediaCard = ({ item, wide = false }: MediaCardProps) => {
|
||||
const { token } = useClient();
|
||||
const { pathname } = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const src = wide ? thumbUrl(item, token) : posterUrl(item, token);
|
||||
const progress = progressPercent(item);
|
||||
const watched = item.UserData?.Played === true;
|
||||
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||
const subtitle = subtitleFor(item);
|
||||
|
||||
return (
|
||||
<div className="group/card flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Link
|
||||
to={itemHref(pathname, params, item.Id)}
|
||||
className={`relative block overflow-hidden rounded-lg bg-muted ring-1 ring-black/5 transition-transform hover:scale-[1.02] ${
|
||||
wide ? 'aspect-video' : 'aspect-[2/3]'
|
||||
}`}
|
||||
title={item.Name ?? undefined}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center p-2 text-center text-[11px] text-muted-foreground">
|
||||
{item.Name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{watched && (
|
||||
<span className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
{!watched && unplayed > 0 && (
|
||||
<span className="absolute right-1.5 top-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
|
||||
{unplayed}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{progress > 0 && progress < 100 && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||
<span className="block h-full bg-primary" style={{ width: `${progress}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={itemHref(pathname, params, item.Id, true)}
|
||||
aria-label={`Play ${item.Name ?? 'item'}`}
|
||||
className="absolute left-1/2 top-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover/card:opacity-100"
|
||||
>
|
||||
<Play className="h-5 w-5 translate-x-px" fill="currentColor" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium leading-tight">{episodeLabel(item)}</p>
|
||||
{subtitle && <p className="truncate text-[11px] text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** A horizontal shelf. Overflow scrolls rather than wrapping — a shelf that wraps is a grid with a title. */
|
||||
export const MediaShelf = ({ title, items, wide }: { title: string; items: JellyItem[]; wide?: boolean }) => {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="px-1 text-sm font-semibold">{title}</h3>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.Id} className={wide ? 'w-56 shrink-0' : 'w-32 shrink-0'}>
|
||||
<MediaCard item={item} wide={wide} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/** A wrapping grid. Column count comes from a min tile width, so it works in a panel of any size. */
|
||||
export const MediaGrid = ({ items, wide }: { items: JellyItem[]; wide?: boolean }) => (
|
||||
<div
|
||||
className="grid gap-x-3 gap-y-4"
|
||||
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${wide ? 200 : 130}px, 1fr))` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MediaCard key={item.Id} item={item} wide={wide} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,404 @@
|
||||
import type { AudioTrack, QualityRung, SubtitleTrack } from './shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Gauge,
|
||||
Languages,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
Subtitles,
|
||||
Volume1,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react';
|
||||
import { formatClock } from './shared';
|
||||
|
||||
// The control bar. Ours rather than the browser's for every transport, so that the progressive fallback — whose
|
||||
// scrubber has to re-request the stream — is not a visibly different player from the seekable ones.
|
||||
//
|
||||
// THE MENUS ARE HAND-ROLLED, and that is not an oversight. A Radix/shadcn dropdown renders through a portal
|
||||
// into document.body, and body is not inside the element that went fullscreen — so every one of these would be
|
||||
// invisible exactly when the player is most likely to be used. These stay in the DOM subtree they belong to.
|
||||
|
||||
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white/90 hover:bg-white/15';
|
||||
const ROW = 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-white/10';
|
||||
|
||||
type MenuProps = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
children: (close: () => void) => React.ReactNode;
|
||||
};
|
||||
|
||||
const Menu = ({ icon, label, active, children }: MenuProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointer = (ev: PointerEvent) => {
|
||||
if (!wrapper.current?.contains(ev.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
// Escape closes the menu rather than leaving fullscreen, but only while one is open.
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointer, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointer, true);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapper} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
className={`${BUTTON} ${active ? 'text-primary' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute bottom-10 right-0 z-10 max-h-72 min-w-56 overflow-y-auto rounded-lg border border-white/10 bg-black/90 p-1 text-white shadow-xl backdrop-blur">
|
||||
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-white/40">{label}</p>
|
||||
{children(() => setOpen(false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type OptionProps = { selected: boolean; onSelect: () => void; children: React.ReactNode; hint?: string | null };
|
||||
|
||||
const Option = ({ selected, onSelect, children, hint }: OptionProps) => (
|
||||
<button type="button" onClick={onSelect} className={ROW}>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100 text-primary' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{children}</span>
|
||||
{hint && <span className="block truncate text-[10px] text-white/40">{hint}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export type PlayerControlsProps = {
|
||||
playing: boolean;
|
||||
position: number;
|
||||
duration: number;
|
||||
/** Seconds buffered ahead of the current position, as one number — the element's own ranges, flattened. */
|
||||
bufferedTo: number;
|
||||
/** False in progressive mode, where a scrub costs a new transcode and only commits on release. */
|
||||
liveScrub: boolean;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
rate: number;
|
||||
fullscreen: boolean;
|
||||
audio: AudioTrack[];
|
||||
audioIndex: number | null;
|
||||
subtitles: SubtitleTrack[];
|
||||
subtitleIndex: number | null;
|
||||
qualityRungs: QualityRung[];
|
||||
maxBitrate: number | null;
|
||||
/** Set while a re-negotiation is in flight, so a picker click reads as busy rather than as nothing. */
|
||||
busy: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onScrub: (seconds: number) => void;
|
||||
onScrubCommit: (seconds: number) => void;
|
||||
onSkip: (seconds: number) => void;
|
||||
onVolume: (volume: number) => void;
|
||||
onToggleMute: () => void;
|
||||
onRate: (rate: number) => void;
|
||||
onAudio: (index: number) => void;
|
||||
onSubtitle: (index: number | null) => void;
|
||||
onQuality: (bitrate: number | null) => void;
|
||||
onFullscreen: () => void;
|
||||
};
|
||||
|
||||
export const PlayerControls = (props: PlayerControlsProps) => {
|
||||
const { position, duration, bufferedTo, liveScrub } = props;
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
|
||||
const shown = dragging ?? position;
|
||||
const pct = (seconds: number) => (duration > 0 ? Math.min(100, Math.max(0, (seconds / duration) * 100)) : 0);
|
||||
|
||||
const secondsAt = (clientX: number): number => {
|
||||
const box = barRef.current?.getBoundingClientRect();
|
||||
if (!box || box.width === 0) return 0;
|
||||
return Math.max(0, Math.min(duration, ((clientX - box.left) / box.width) * duration));
|
||||
};
|
||||
|
||||
// Pointer capture rather than window listeners: the drag keeps tracking when the cursor leaves the bar, and
|
||||
// it ends on the same element it started on even if that is over the video or outside the window.
|
||||
const onPointerDown = (ev: React.PointerEvent<HTMLDivElement>) => {
|
||||
ev.currentTarget.setPointerCapture(ev.pointerId);
|
||||
const seconds = secondsAt(ev.clientX);
|
||||
setDragging(seconds);
|
||||
if (liveScrub) props.onScrub(seconds);
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: React.PointerEvent<HTMLDivElement>) => {
|
||||
const seconds = secondsAt(ev.clientX);
|
||||
setHover(seconds);
|
||||
if (dragging === null) return;
|
||||
setDragging(seconds);
|
||||
// In progressive mode every intermediate value would start an ffmpeg, so only the release commits.
|
||||
if (liveScrub) props.onScrub(seconds);
|
||||
};
|
||||
|
||||
const onPointerUp = (ev: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragging === null) return;
|
||||
ev.currentTarget.releasePointerCapture(ev.pointerId);
|
||||
props.onScrubCommit(secondsAt(ev.clientX));
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
const currentSubtitle = props.subtitles.find((track) => track.index === props.subtitleIndex) ?? null;
|
||||
const currentQuality = props.qualityRungs.find((rung) => rung.videoBitrate === props.maxBitrate) ?? null;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-t from-black/90 to-transparent px-3 pb-2 pt-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-12 shrink-0 text-right text-[11px] tabular-nums text-white/70">{formatClock(shown)}</span>
|
||||
|
||||
<div
|
||||
ref={barRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(1, Math.round(duration))}
|
||||
aria-valuenow={Math.round(shown)}
|
||||
aria-valuetext={formatClock(shown)}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
className="group relative h-6 flex-1 cursor-pointer touch-none select-none"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/20">
|
||||
<div className="h-full rounded-full bg-white/30" style={{ width: `${pct(bufferedTo)}%` }} />
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary"
|
||||
style={{ width: `${pct(shown)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ left: `${pct(shown)}%`, opacity: dragging !== null ? 1 : undefined }}
|
||||
/>
|
||||
{hover !== null && duration > 0 && (
|
||||
<span
|
||||
className="pointer-events-none absolute -top-5 -translate-x-1/2 rounded bg-black/80 px-1 text-[10px] tabular-nums text-white"
|
||||
style={{ left: `${pct(hover)}%` }}
|
||||
>
|
||||
{formatClock(hover)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="w-12 shrink-0 text-[11px] tabular-nums text-white/70">{formatClock(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onTogglePlay}
|
||||
aria-label={props.playing ? 'Pause' : 'Play'}
|
||||
className={BUTTON}
|
||||
>
|
||||
{props.playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" fill="currentColor" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onSkip(-10)} aria-label="Back 10 seconds" className={BUTTON}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onSkip(30)} aria-label="Forward 30 seconds" className={BUTTON}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* The slider stays out of the tab order on purpose — the volume button is the keyboard affordance,
|
||||
and a range input here would swallow the arrow keys the player binds to seeking. */}
|
||||
<div className="group/vol flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleMute}
|
||||
aria-label={props.muted ? 'Unmute' : 'Mute'}
|
||||
className={BUTTON}
|
||||
>
|
||||
{props.muted || props.volume === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : props.volume < 0.5 ? (
|
||||
<Volume1 className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.02}
|
||||
tabIndex={-1}
|
||||
value={props.muted ? 0 : props.volume}
|
||||
onChange={(ev) => props.onVolume(Number(ev.target.value))}
|
||||
aria-label="Volume"
|
||||
className="h-1 w-0 cursor-pointer accent-primary opacity-0 transition-all group-hover/vol:w-20 group-hover/vol:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
{props.busy && <span className="mr-1 text-[10px] text-white/50">switching…</span>}
|
||||
|
||||
<Menu icon={<Subtitles className="h-4 w-4" />} label="Subtitles" active={props.subtitleIndex !== null}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Option
|
||||
selected={props.subtitleIndex === null}
|
||||
onSelect={() => {
|
||||
props.onSubtitle(null);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Off
|
||||
</Option>
|
||||
{props.subtitles.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-[11px] text-white/40">This file has no subtitle tracks.</p>
|
||||
)}
|
||||
{props.subtitles.map((track) =>
|
||||
track.url ? (
|
||||
<Option
|
||||
key={track.index}
|
||||
selected={props.subtitleIndex === track.index}
|
||||
onSelect={() => {
|
||||
props.onSubtitle(track.index);
|
||||
close();
|
||||
}}
|
||||
hint={track.isForced ? 'forced' : null}
|
||||
>
|
||||
{track.label}
|
||||
</Option>
|
||||
) : (
|
||||
<p
|
||||
key={track.index}
|
||||
className="px-2 py-1.5 text-[11px] text-white/30"
|
||||
title={track.unavailable ?? ''}
|
||||
>
|
||||
<span className="block truncate line-through">{track.label}</span>
|
||||
<span className="block text-[10px]">unavailable — {track.unavailable}</span>
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<Menu icon={<Languages className="h-4 w-4" />} label="Audio track">
|
||||
{(close) => (
|
||||
<>
|
||||
{props.audio.map((track) => (
|
||||
<Option
|
||||
key={track.index}
|
||||
selected={props.audioIndex === track.index || (props.audioIndex === null && track.isDefault)}
|
||||
onSelect={() => {
|
||||
props.onAudio(track.index);
|
||||
close();
|
||||
}}
|
||||
hint={[track.codec?.toUpperCase(), track.channels ? `${track.channels}ch` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
>
|
||||
{track.label}
|
||||
</Option>
|
||||
))}
|
||||
{props.audio.length <= 1 && (
|
||||
<p className="px-2 py-1.5 text-[11px] text-white/40">Only one audio track.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<Menu icon={<Gauge className="h-4 w-4" />} label="Speed and quality">
|
||||
{(close) => (
|
||||
<>
|
||||
<p className="px-2 pt-1 text-[10px] uppercase tracking-wide text-white/40">Speed</p>
|
||||
{PLAYBACK_RATES.map((rate) => (
|
||||
<Option
|
||||
key={rate}
|
||||
selected={props.rate === rate}
|
||||
onSelect={() => {
|
||||
props.onRate(rate);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{rate === 1 ? 'Normal' : `${rate}×`}
|
||||
</Option>
|
||||
))}
|
||||
<p className="mt-1 border-t border-white/10 px-2 pt-2 text-[10px] uppercase tracking-wide text-white/40">
|
||||
Quality
|
||||
</p>
|
||||
<Option
|
||||
selected={props.maxBitrate === null}
|
||||
onSelect={() => {
|
||||
props.onQuality(null);
|
||||
close();
|
||||
}}
|
||||
hint="direct play or remux where possible"
|
||||
>
|
||||
Auto
|
||||
</Option>
|
||||
{props.qualityRungs.map((rung) => (
|
||||
<Option
|
||||
key={rung.videoBitrate}
|
||||
selected={props.maxBitrate === rung.videoBitrate}
|
||||
onSelect={() => {
|
||||
props.onQuality(rung.videoBitrate);
|
||||
close();
|
||||
}}
|
||||
hint={`${Math.round(rung.videoBitrate / 1_000_000)} Mbit · forces a re-encode`}
|
||||
>
|
||||
{rung.label}
|
||||
</Option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onFullscreen}
|
||||
aria-label={props.fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
className={BUTTON}
|
||||
>
|
||||
{props.fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(currentSubtitle || currentQuality) && (
|
||||
<p className="truncate px-1 text-[10px] text-white/35">
|
||||
{[
|
||||
currentSubtitle && `subtitles: ${currentSubtitle.label}`,
|
||||
currentQuality && `quality: ${currentQuality.label}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MediaGrid } from './MediaCard';
|
||||
import { QUERY_PARAM } from './shared';
|
||||
import { useJellyfinSearch } from './useJellyfinData';
|
||||
|
||||
// Search across every video library.
|
||||
//
|
||||
// The term is `?q=` — in the URL, not in component state, so a result set is a link and the browser's own
|
||||
// back button steps through what you looked for. `replace` on every keystroke keeps that history one entry
|
||||
// per search rather than one per letter.
|
||||
|
||||
export const SearchSection = () => {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const term = params.get(QUERY_PARAM) ?? '';
|
||||
const { data, isFetching } = useJellyfinSearch(term);
|
||||
|
||||
const setTerm = (value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(QUERY_PARAM, value);
|
||||
else next.delete(QUERY_PARAM);
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const items = data?.Items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
value={term}
|
||||
onChange={(ev) => setTerm(ev.target.value)}
|
||||
placeholder="Search films, shows and episodes"
|
||||
className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
/>
|
||||
{isFetching && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{term.trim().length < 2 ? (
|
||||
<p className="pt-10 text-center text-xs text-muted-foreground">Type at least two characters.</p>
|
||||
) : items.length ? (
|
||||
<MediaGrid items={items} />
|
||||
) : (
|
||||
!isFetching && <p className="pt-10 text-center text-xs text-muted-foreground">Nothing matched “{term}”.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { jellyfinSectionPath } from './shared';
|
||||
import { useJellyfinHealth, useJellyfinServerActions, useJellyfinServers } from './useJellyfinData';
|
||||
|
||||
// Which Jellyfin you are looking at, and how to change it.
|
||||
//
|
||||
// This machine really does run several instances, so the switcher is not hypothetical — but only the video
|
||||
// one is expected here; the album and DJ-set instances are the music player's business, and the sidecar
|
||||
// already drops music libraries from every listing.
|
||||
//
|
||||
// Switching invalidates the whole ['jellyfin'] key, because it changes the answer to every query in the
|
||||
// workspace without changing any of their inputs. Anything less and the previous server's posters stay up.
|
||||
|
||||
export const ServerSwitcher = () => {
|
||||
const { data } = useJellyfinServers();
|
||||
const { data: health } = useJellyfinHealth();
|
||||
const { activate } = useJellyfinServerActions();
|
||||
|
||||
const servers = data?.servers ?? [];
|
||||
const active = servers.find((server) => server.isActive) ?? null;
|
||||
|
||||
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||
const status = active?.label ?? (health?.ok ? (health.serverName ?? 'connected') : 'not connected');
|
||||
|
||||
if (servers.length < 2) {
|
||||
return (
|
||||
<Link
|
||||
to={jellyfinSectionPath('settings')}
|
||||
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{status}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="truncate">{status}</span>
|
||||
{activate.isPending ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Jellyfin servers</DropdownMenuLabel>
|
||||
{servers.map((server) => (
|
||||
<DropdownMenuItem
|
||||
key={server.id}
|
||||
disabled={server.isActive || activate.isPending}
|
||||
onSelect={() => void activate.mutateAsync(server.id).catch(() => undefined)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{server.label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{server.url}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={jellyfinSectionPath('settings')} className="gap-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage servers
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,506 @@
|
||||
import type { PlaybackPlan } from './shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { closeHref, episodeLabel, PLAY_PARAM, streamUrl, TICKS_PER_SECOND, ticksToSeconds } from './shared';
|
||||
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
|
||||
import { PlayerControls } from './PlayerControls';
|
||||
import Hls from './vendor/hls.mjs';
|
||||
|
||||
// The player — a full-panel overlay opened by `?play=<id>`.
|
||||
//
|
||||
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: what the server hands back is not always a
|
||||
// file. There are three transports, and `mode` names which one is in use rather than letting the difference
|
||||
// leak out as mysterious behaviour:
|
||||
//
|
||||
// file — direct play or direct stream. A real byte-range resource; native seeking, `currentTime` is
|
||||
// the position, and a scrub can follow the pointer.
|
||||
// hls-mse — a transcode, driven by the vendored hls.js over Media Source Extensions. A real timeline
|
||||
// too: Jellyfin's playlist is VOD and spans the whole runtime, so a scrub is a segment
|
||||
// request and the server moves its ffmpeg to that point. Committed on release, because each
|
||||
// intermediate position would be a real repositioning of that ffmpeg.
|
||||
// progressive — the fallback for a browser without MSE. One endless mp4 with no length and no ranges, so
|
||||
// the browser CANNOT seek it: seeking means asking the server for a NEW stream at an offset,
|
||||
// and the position is `startSeconds + currentTime`.
|
||||
//
|
||||
// `hasTimeline` is the single flag every other decision here reads.
|
||||
//
|
||||
// Safari plays an m3u8 natively and is deliberately NOT given one. Segment URIs inside the playlist are
|
||||
// relative, so they inherit the playlist's path but not its `?token=` — every segment would come back 401,
|
||||
// and Safari cannot set an Authorization header the way hls.js can. Where hls.js runs (including iOS 17.1+,
|
||||
// via Managed Media Source) it is used; where it does not, progressive is the honest fallback.
|
||||
//
|
||||
// The controls are ours in every mode. Not for looks: progressive has no timeline the browser can render, and
|
||||
// an audio-track or quality change is a re-negotiation with the server rather than something a <video> knows
|
||||
// how to do. One control bar for all three is what keeps those from being three different players.
|
||||
//
|
||||
// NO SCRUBBER THUMBNAILS. They come from Jellyfin's trickplay tiles, and this server generates none
|
||||
// (`item.Trickplay` is `{}`) — a hover preview would be a permanently empty box.
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 10_000;
|
||||
const CHROME_HIDE_MS = 2_500;
|
||||
/** Manhattan pixels a pointer must travel before it counts as the user, rather than as a resting mouse. */
|
||||
const WAKE_THRESHOLD_PX = 8;
|
||||
const VOLUME_KEY = 'jellyfin:volume';
|
||||
|
||||
type Mode = 'file' | 'hls-mse' | 'progressive';
|
||||
|
||||
function transportFor(plan: PlaybackPlan): Mode {
|
||||
if (plan.seekable) return 'file';
|
||||
if (plan.hlsUrl && Hls.isSupported()) return 'hls-mse';
|
||||
return 'progressive';
|
||||
}
|
||||
|
||||
function readStoredVolume(): { volume: number; muted: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(VOLUME_KEY);
|
||||
if (!raw) return { volume: 1, muted: false };
|
||||
const parsed = JSON.parse(raw) as { volume?: number; muted?: boolean };
|
||||
return { volume: Math.min(1, Math.max(0, parsed.volume ?? 1)), muted: !!parsed.muted };
|
||||
} catch {
|
||||
return { volume: 1, muted: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** How far the element has buffered past the point being played, flattened to one number for the bar. */
|
||||
function bufferedAhead(video: HTMLVideoElement): number {
|
||||
const { buffered, currentTime } = video;
|
||||
for (let i = 0; i < buffered.length; i += 1) {
|
||||
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) return buffered.end(i);
|
||||
}
|
||||
return currentTime;
|
||||
}
|
||||
|
||||
export const VideoPlayer = ({ id }: { id: string }) => {
|
||||
const { token } = useClient();
|
||||
const { pathname } = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const { data: item } = useJellyfinItem(id);
|
||||
const negotiate = usePlaybackPlan();
|
||||
const report = usePlaystateReporter();
|
||||
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [plan, setPlan] = useState<PlaybackPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [bufferedTo, setBufferedTo] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const stored = useMemo(readStoredVolume, []);
|
||||
const [volume, setVolume] = useState(stored.volume);
|
||||
const [muted, setMuted] = useState(stored.muted);
|
||||
const [rate, setRate] = useState(1);
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number | null>(null);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [chrome, setChrome] = useState(true);
|
||||
|
||||
// Refs shadow the state the unmount cleanup needs. A cleanup closes over the values from the render that
|
||||
// registered it, and the last thing this component does — report where playback stopped — must use the
|
||||
// position at the moment of unmount, not the one from whenever the effect last ran.
|
||||
const planRef = useRef<PlaybackPlan | null>(null);
|
||||
const positionRef = useRef(0);
|
||||
const startedRef = useRef(false);
|
||||
const idRef = useRef(id);
|
||||
idRef.current = id;
|
||||
// React Query hands back a new mutation object each render, so this is the same story as `report` below:
|
||||
// keeping `open` stable is what keeps the start-once effect from re-running on every frame of playback.
|
||||
const negotiateRef = useRef(negotiate);
|
||||
negotiateRef.current = negotiate;
|
||||
// The chosen track and rung, kept in refs so `open` does not have to be rebuilt (and the start-once effect
|
||||
// re-run) every time one of them changes.
|
||||
const audioRef = useRef<number | null>(null);
|
||||
const bitrateRef = useRef<number | null>(null);
|
||||
// Where the pointer was when it last counted as movement — see `onPointerMove`.
|
||||
const pointerRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const mode = useMemo(() => (plan ? transportFor(plan) : null), [plan]);
|
||||
const hasTimeline = mode !== null && mode !== 'progressive';
|
||||
const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
|
||||
const subtitle = plan?.subtitles.find((track) => track.index === subtitleIndex && track.url) ?? null;
|
||||
|
||||
// `report` is a NEW function on every render — `useClient()` rebuilds its verbs each time — so it is held in
|
||||
// a ref rather than named as a dependency. This is not a micro-optimisation: an unstable `sendReport` made
|
||||
// the "report where playback stopped" cleanup below re-run on every render, which nulled `planRef` a few
|
||||
// milliseconds after the stream opened and turned every later report into a no-op. Nothing ever reached
|
||||
// Jellyfin, so nothing ever appeared in Continue Watching.
|
||||
const reportRef = useRef(report);
|
||||
reportRef.current = report;
|
||||
|
||||
const sendReport = useCallback((action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
|
||||
const current = planRef.current;
|
||||
if (!current) return;
|
||||
reportRef.current(action, {
|
||||
ItemId: idRef.current,
|
||||
PlaySessionId: current.playSessionId,
|
||||
MediaSourceId: current.mediaSourceId,
|
||||
PositionTicks: Math.max(0, Math.round(positionRef.current * TICKS_PER_SECOND)),
|
||||
IsPaused: isPaused,
|
||||
CanSeek: current.seekable,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Open (or re-open) the stream. In progressive mode a seek is exactly this call; so is any audio-track or
|
||||
* quality change in every mode, because both are decided during negotiation and baked into the stream.
|
||||
*/
|
||||
const open = useCallback(
|
||||
async (startSeconds: number) => {
|
||||
// Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
|
||||
// Jellyfin shows two sessions playing the same thing.
|
||||
if (planRef.current) sendReport('stopped');
|
||||
setError(null);
|
||||
try {
|
||||
const next = await negotiateRef.current.mutateAsync({
|
||||
id: idRef.current,
|
||||
startSeconds,
|
||||
audioStreamIndex: audioRef.current ?? undefined,
|
||||
maxStreamingBitrate: bitrateRef.current ?? undefined,
|
||||
});
|
||||
planRef.current = next;
|
||||
positionRef.current = next.startSeconds;
|
||||
setPlan(next);
|
||||
setPosition(next.startSeconds);
|
||||
setBufferedTo(next.startSeconds);
|
||||
} catch (err) {
|
||||
setError((err as { message?: string } | null)?.message ?? 'Playback could not be started');
|
||||
}
|
||||
},
|
||||
[sendReport],
|
||||
);
|
||||
|
||||
// Start once, at the stored resume point. Guarded by a ref rather than by the dependency list because
|
||||
// React runs effects twice in development and a second negotiation would start a second transcode.
|
||||
useEffect(() => {
|
||||
if (startedRef.current || !item) return;
|
||||
startedRef.current = true;
|
||||
void open(ticksToSeconds(item.UserData?.PlaybackPositionTicks));
|
||||
}, [item, open]);
|
||||
|
||||
// hls.js owns the element's buffer in `hls-mse` mode, so the element gets no `src` at all — attaching one
|
||||
// alongside MSE is how you get two sources fighting over the same video.
|
||||
//
|
||||
// The credential goes on as a header rather than in the URL because hls.js fetches every segment itself:
|
||||
// segment URIs are RELATIVE to the playlist, so a `?token=` on the playlist would not survive onto them.
|
||||
// `xhrSetup` runs before hls.js opens the request and it skips its own `open` if one already happened,
|
||||
// which is what makes setting a header here possible at all.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (mode !== 'hls-mse' || !plan?.hlsUrl || !video) return;
|
||||
|
||||
const hls = new Hls({
|
||||
startPosition: plan.startSeconds > 0 ? plan.startSeconds : -1,
|
||||
xhrSetup: (xhr, url) => {
|
||||
xhr.open('GET', url, true);
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Non-fatal errors are hls.js's normal weather and it recovers from them itself. A fatal one is worth one
|
||||
// attempt at the documented recovery for its kind before giving the player back to the user as broken.
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return;
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
|
||||
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
|
||||
else setError('The stream stopped unexpectedly.');
|
||||
});
|
||||
|
||||
hls.loadSource(`/api/jellyfin${plan.hlsUrl}`);
|
||||
hls.attachMedia(video);
|
||||
return () => hls.destroy();
|
||||
}, [mode, plan, token]);
|
||||
|
||||
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
|
||||
// This must run exactly once, at unmount: the cleanup nulls `planRef`, so anything that made it re-run
|
||||
// mid-playback would silently switch off every report that follows. `sendReport` is `useCallback(…, [])`
|
||||
// and never changes identity, which is what makes naming it here equivalent to an empty list — and is why
|
||||
// it has to stay that way. If it ever grows a dependency, this becomes the Continue Watching bug again.
|
||||
useEffect(
|
||||
() => () => {
|
||||
sendReport('stopped');
|
||||
planRef.current = null;
|
||||
},
|
||||
[sendReport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!plan) return;
|
||||
const timer = setInterval(() => sendReport('progress', videoRef.current?.paused ?? false), PROGRESS_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [plan, sendReport]);
|
||||
|
||||
// Volume, mute and rate are element properties, not attributes — React will not set them from JSX, and they
|
||||
// have to be re-applied after the element remounts (which in progressive mode is every scrub).
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.volume = volume;
|
||||
video.muted = muted;
|
||||
video.playbackRate = rate;
|
||||
}, [volume, muted, rate, plan]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
}, [volume, muted]);
|
||||
|
||||
// Only the selected subtitle is rendered as a <track>, so Jellyfin is asked to extract exactly one — and
|
||||
// `mode` has to be set imperatively because `default` only applies on the element's first parse.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
for (let i = 0; i < video.textTracks.length; i += 1) {
|
||||
video.textTracks[i]!.mode = subtitle ? 'showing' : 'disabled';
|
||||
}
|
||||
}, [subtitle, plan]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setFullscreen(document.fullscreenElement === shellRef.current);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
// Auto-hide, but never while paused — a paused player with no controls looks broken rather than clean.
|
||||
//
|
||||
// `position` must NOT be a dependency, however much it reads like activity. It advances ~4x a second from
|
||||
// onTimeUpdate, so naming it here re-ran this effect — and so cleared the pending timer — long before 2.5s
|
||||
// had passed. The chrome could then only hide while playback was stopped, which is to say never. Real
|
||||
// activity is `wake()`, which sets `chrome` back to true and restarts the timer through the dep below.
|
||||
useEffect(() => {
|
||||
if (!chrome || !playing) return;
|
||||
const timer = setTimeout(() => setChrome(false), CHROME_HIDE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [chrome, playing]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
const video = videoRef.current;
|
||||
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
|
||||
if (hasTimeline && video) {
|
||||
video.currentTime = clamped;
|
||||
return;
|
||||
}
|
||||
void open(clamped);
|
||||
},
|
||||
[duration, hasTimeline, open],
|
||||
);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) void video.play().catch(() => undefined);
|
||||
else video.pause();
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
// The container, not the <video> — fullscreening the element itself would take the controls with it into
|
||||
// a UA-drawn shell that has none of these buttons.
|
||||
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
|
||||
else void shellRef.current?.requestFullscreen().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const changeAudio = (index: number) => {
|
||||
audioRef.current = index;
|
||||
void open(positionRef.current);
|
||||
};
|
||||
|
||||
const changeQuality = (bitrate: number | null) => {
|
||||
bitrateRef.current = bitrate;
|
||||
void open(positionRef.current);
|
||||
};
|
||||
|
||||
const wake = () => setChrome(true);
|
||||
|
||||
/**
|
||||
* Wake on real movement only.
|
||||
*
|
||||
* A bare `pointermove` handler is too credulous to sit in front of a two-hour film: a mouse resting on a
|
||||
* desk still emits the odd one-pixel event, a bumped table emits a few, and a browser will synthesise a
|
||||
* zero-delta move of its own when the cursor style changes — which this player does every time it hides the
|
||||
* cursor. Any one of those read as "the user is here" and put the scrubber back for two and a half seconds,
|
||||
* minutes apart, with nobody having touched anything.
|
||||
*
|
||||
* The anchor only moves when a wake actually happens, so a slow deliberate drift still accumulates past the
|
||||
* threshold and wakes; it is jitter around a fixed point that never does.
|
||||
*/
|
||||
const onPointerMove = (ev: React.PointerEvent) => {
|
||||
const last = pointerRef.current;
|
||||
if (last && Math.abs(ev.clientX - last.x) + Math.abs(ev.clientY - last.y) < WAKE_THRESHOLD_PX) return;
|
||||
pointerRef.current = { x: ev.clientX, y: ev.clientY };
|
||||
wake();
|
||||
};
|
||||
|
||||
// Keyboard on the container rather than on window: the player is a panel inside a workspace, and stealing
|
||||
// space or the arrow keys from whatever else is on screen would be wrong. Autofocus puts it in reach.
|
||||
const onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
const bump = (delta: number) => setVolume((was) => Math.min(1, Math.max(0, was + delta)));
|
||||
const keys: Record<string, () => void> = {
|
||||
' ': togglePlay,
|
||||
k: togglePlay,
|
||||
ArrowLeft: () => seek(positionRef.current - 10),
|
||||
j: () => seek(positionRef.current - 10),
|
||||
ArrowRight: () => seek(positionRef.current + 10),
|
||||
l: () => seek(positionRef.current + 10),
|
||||
ArrowUp: () => bump(0.05),
|
||||
ArrowDown: () => bump(-0.05),
|
||||
m: () => setMuted((was) => !was),
|
||||
f: toggleFullscreen,
|
||||
c: () => setSubtitleIndex((was) => (was !== null ? null : (plan?.subtitles.find((s) => s.url)?.index ?? null))),
|
||||
};
|
||||
const action = keys[ev.key];
|
||||
if (!action) return;
|
||||
ev.preventDefault();
|
||||
wake();
|
||||
action();
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !plan) return;
|
||||
// Only progressive starts the element's clock at zero for a stream that begins mid-film.
|
||||
const offset = hasTimeline ? 0 : plan.startSeconds;
|
||||
positionRef.current = offset + video.currentTime;
|
||||
setPosition(offset + video.currentTime);
|
||||
setBufferedTo(offset + bufferedAhead(video));
|
||||
};
|
||||
|
||||
// `src` for everything except MSE, where hls.js feeds the element instead. The key remounts the element when
|
||||
// the source genuinely changes — which in progressive mode is every scrub.
|
||||
const source = plan && mode !== 'hls-mse' ? streamUrl(plan.url, token) : null;
|
||||
|
||||
const close = closeHref(pathname, params, PLAY_PARAM);
|
||||
const title = item ? episodeLabel(item) : '';
|
||||
const idle = playing && !chrome;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={shellRef}
|
||||
tabIndex={-1}
|
||||
autoFocus
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerDown={wake}
|
||||
className={`absolute inset-0 z-30 bg-black outline-none ${idle ? 'cursor-none' : ''}`}
|
||||
>
|
||||
{/* Chrome floats OVER the video rather than sitting beside it in a column, so that going fullscreen —
|
||||
which fullscreens this container — does not change the video's size or the layout around it. */}
|
||||
<div
|
||||
className={`absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-gradient-to-b from-black/80 to-transparent px-3 py-2 text-white transition-opacity ${
|
||||
idle ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{title}</span>
|
||||
{plan && plan.playMethod !== 'DirectPlay' && (
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
|
||||
{plan.playMethod === 'Transcode' ? 'transcoding' : 'remuxing'}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={close}
|
||||
aria-label="Close player"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-white/10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black">
|
||||
{error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center text-white">
|
||||
<p className="text-sm font-medium">This will not play</p>
|
||||
<p className="max-w-md text-xs text-white/60">{error}</p>
|
||||
</div>
|
||||
) : plan ? (
|
||||
<video
|
||||
key={mode === 'hls-mse' ? (plan.hlsUrl ?? plan.url) : plan.url}
|
||||
ref={videoRef}
|
||||
src={source ?? undefined}
|
||||
className="h-full w-full"
|
||||
autoPlay
|
||||
playsInline
|
||||
onClick={togglePlay}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onProgress={onTimeUpdate}
|
||||
onPlay={() => {
|
||||
setPlaying(true);
|
||||
sendReport('playing');
|
||||
}}
|
||||
onPause={() => {
|
||||
setPlaying(false);
|
||||
setChrome(true);
|
||||
sendReport('progress', true);
|
||||
}}
|
||||
onEnded={() => {
|
||||
setPlaying(false);
|
||||
sendReport('stopped');
|
||||
}}
|
||||
// hls.js reports its own errors through the ERROR event, and sets a MediaError on the element for
|
||||
// ones it is already recovering from — so in MSE mode this handler would fire on the recoverable.
|
||||
onError={() => mode !== 'hls-mse' && setError('The stream stopped unexpectedly.')}
|
||||
>
|
||||
{/* A <track> cannot send headers, so this is the `?token=` form. Safe here in a way an m3u8 is
|
||||
not: it is one self-contained file with no relative children to lose the query string. */}
|
||||
{subtitle && (
|
||||
<track
|
||||
key={subtitle.index}
|
||||
kind="subtitles"
|
||||
src={streamUrl(subtitle.url!, token)}
|
||||
srcLang={subtitle.language ?? undefined}
|
||||
label={subtitle.label}
|
||||
default
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-white/60">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan && !error && (
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 z-10 transition-opacity ${
|
||||
idle ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<PlayerControls
|
||||
playing={playing}
|
||||
position={position}
|
||||
duration={duration}
|
||||
bufferedTo={bufferedTo}
|
||||
liveScrub={mode === 'file'}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
rate={rate}
|
||||
fullscreen={fullscreen}
|
||||
audio={plan.audio}
|
||||
audioIndex={plan.audioStreamIndex}
|
||||
subtitles={plan.subtitles}
|
||||
subtitleIndex={subtitleIndex}
|
||||
qualityRungs={plan.qualityRungs}
|
||||
maxBitrate={plan.maxStreamingBitrate}
|
||||
busy={negotiate.isPending}
|
||||
onTogglePlay={togglePlay}
|
||||
onScrub={seek}
|
||||
onScrubCommit={seek}
|
||||
onSkip={(delta) => seek(positionRef.current + delta)}
|
||||
onVolume={(next) => {
|
||||
setVolume(next);
|
||||
setMuted(next === 0);
|
||||
}}
|
||||
onToggleMute={() => setMuted((was) => !was)}
|
||||
onRate={setRate}
|
||||
onAudio={changeAudio}
|
||||
onSubtitle={setSubtitleIndex}
|
||||
onQuality={changeQuality}
|
||||
onFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'jellyfin-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'jellyfin-nav', appType: 'jellyfin-nav' }, size: 18 },
|
||||
{ node: { type: 'panel', id: 'jellyfin-view', appType: 'jellyfin-view' }, size: 82 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Clapperboard, PanelLeft } from 'lucide-react';
|
||||
import { JellyfinNav } from './JellyfinNav';
|
||||
import { JellyfinView } from './JellyfinView';
|
||||
import { JellyfinViewHeader } from './JellyfinViewHeader';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'jellyfin-nav', name: 'Video', icon: PanelLeft, component: JellyfinNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'jellyfin-view',
|
||||
name: 'Video',
|
||||
icon: Clapperboard,
|
||||
component: JellyfinView,
|
||||
header: JellyfinViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
// Shared types, constants and pure helpers for the /jellyfin workspace panels.
|
||||
//
|
||||
// The types mirror Jellyfin's `BaseItemDto` rather than reshaping it, because the sidecar's `/_officer/*`
|
||||
// façade is a pass-through for the item shapes themselves — what it adds is the user id, the Fields list and
|
||||
// the playback negotiation, not a different vocabulary. Every field here is optional in Jellyfin's own schema
|
||||
// and is optional here too: an Episode has no `CollectionType`, a Movie has no `SeriesId`, and a library
|
||||
// mid-scan can return an item with nothing but an Id.
|
||||
//
|
||||
// VIDEO ONLY. The owner's music is Officer's own /music player, and this machine runs separate Jellyfin
|
||||
// instances for albums and DJ sets; the sidecar already filters music libraries out of `/views` and `/home`.
|
||||
|
||||
export const JELLYFIN_SECTIONS = [
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'library', label: 'Library' },
|
||||
{ id: 'search', label: 'Search' },
|
||||
{ id: 'settings', label: 'Connection' },
|
||||
] as const;
|
||||
|
||||
export type JellyfinSectionId = (typeof JELLYFIN_SECTIONS)[number]['id'];
|
||||
|
||||
/** Where /jellyfin lands, and where an unrecognised section redirects to. */
|
||||
export const DEFAULT_JELLYFIN_SECTION: JellyfinSectionId = 'home';
|
||||
|
||||
export const isJellyfinSection = (value: string | undefined): value is JellyfinSectionId =>
|
||||
JELLYFIN_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
|
||||
export const jellyfinSectionPath = (id: JellyfinSectionId) => `/jellyfin/${id}`;
|
||||
|
||||
/** Which library the browse grid is showing — `?view=<id>`. */
|
||||
export const VIEW_PARAM = 'view';
|
||||
/** Which item is open in the detail sheet — `?item=<id>`. */
|
||||
export const ITEM_PARAM = 'item';
|
||||
/** Which item is playing — `?play=<id>`. Separate from `item` so closing the player returns to the detail. */
|
||||
export const PLAY_PARAM = 'play';
|
||||
/** The search text — `?q=…`. */
|
||||
export const QUERY_PARAM = 'q';
|
||||
|
||||
/**
|
||||
* The href that opens an item, keeping the page you opened it from.
|
||||
*
|
||||
* Selection is a query parameter rather than a route because the detail sheet is an overlay ON the grid: the
|
||||
* grid stays mounted and scrolled behind it, and closing is dropping one parameter. Building the href from the
|
||||
* CURRENT params is what makes that work in both directions — open an episode from a search and closing it
|
||||
* puts the search back, unchanged.
|
||||
*/
|
||||
export function itemHref(pathname: string, params: URLSearchParams, id: string, play = false): string {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(play ? PLAY_PARAM : ITEM_PARAM, id);
|
||||
if (play) next.delete(ITEM_PARAM);
|
||||
return `${pathname}?${next}`;
|
||||
}
|
||||
|
||||
/** The href that closes whichever overlay is open, leaving everything else about the page alone. */
|
||||
export function closeHref(pathname: string, params: URLSearchParams, which: typeof ITEM_PARAM | typeof PLAY_PARAM) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete(which);
|
||||
const qs = next.toString();
|
||||
return qs ? `${pathname}?${qs}` : pathname;
|
||||
}
|
||||
|
||||
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type UserItemData = {
|
||||
PlaybackPositionTicks?: number;
|
||||
PlayCount?: number;
|
||||
IsFavorite?: boolean;
|
||||
Played?: boolean;
|
||||
PlayedPercentage?: number | null;
|
||||
UnplayedItemCount?: number;
|
||||
};
|
||||
|
||||
export type MediaStream = {
|
||||
Type?: string;
|
||||
Index?: number;
|
||||
Codec?: string | null;
|
||||
Language?: string | null;
|
||||
DisplayTitle?: string | null;
|
||||
Height?: number | null;
|
||||
Width?: number | null;
|
||||
Channels?: number | null;
|
||||
IsDefault?: boolean;
|
||||
IsForced?: boolean;
|
||||
};
|
||||
|
||||
export type MediaSource = {
|
||||
Id?: string;
|
||||
Name?: string | null;
|
||||
Container?: string | null;
|
||||
Size?: number | null;
|
||||
RunTimeTicks?: number | null;
|
||||
Bitrate?: number | null;
|
||||
MediaStreams?: MediaStream[];
|
||||
SupportsDirectPlay?: boolean;
|
||||
SupportsDirectStream?: boolean;
|
||||
};
|
||||
|
||||
export type Person = { Id?: string; Name?: string; Role?: string | null; Type?: string; PrimaryImageTag?: string };
|
||||
|
||||
export type JellyItem = {
|
||||
Id: string;
|
||||
Name?: string;
|
||||
Type?: string;
|
||||
CollectionType?: string | null;
|
||||
ProductionYear?: number | null;
|
||||
PremiereDate?: string | null;
|
||||
EndDate?: string | null;
|
||||
Status?: string | null;
|
||||
Overview?: string | null;
|
||||
Taglines?: string[];
|
||||
Genres?: string[];
|
||||
OfficialRating?: string | null;
|
||||
CommunityRating?: number | null;
|
||||
CriticRating?: number | null;
|
||||
RunTimeTicks?: number | null;
|
||||
IndexNumber?: number | null;
|
||||
ParentIndexNumber?: number | null;
|
||||
SeriesId?: string | null;
|
||||
SeriesName?: string | null;
|
||||
SeasonId?: string | null;
|
||||
SeasonName?: string | null;
|
||||
ParentId?: string | null;
|
||||
ChildCount?: number | null;
|
||||
RecursiveItemCount?: number | null;
|
||||
PrimaryImageAspectRatio?: number | null;
|
||||
ImageTags?: Record<string, string>;
|
||||
BackdropImageTags?: string[];
|
||||
ParentBackdropItemId?: string | null;
|
||||
ParentBackdropImageTags?: string[];
|
||||
SeriesPrimaryImageTag?: string | null;
|
||||
Studios?: { Id?: string; Name?: string }[];
|
||||
People?: Person[];
|
||||
MediaSources?: MediaSource[];
|
||||
MediaStreams?: MediaStream[];
|
||||
UserData?: UserItemData;
|
||||
};
|
||||
|
||||
export type ItemsResponse = { Items?: JellyItem[]; TotalRecordCount?: number };
|
||||
|
||||
export type HomeResponse = {
|
||||
views: JellyItem[];
|
||||
resume: JellyItem[];
|
||||
nextUp: JellyItem[];
|
||||
latest: { viewId: string; viewName: string; collectionType: string | null; items: JellyItem[] }[];
|
||||
};
|
||||
|
||||
export type AudioTrack = {
|
||||
index: number;
|
||||
label: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
channels: number | null;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type SubtitleTrack = {
|
||||
index: number;
|
||||
label: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
isDefault: boolean;
|
||||
isForced: boolean;
|
||||
/** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */
|
||||
url: string | null;
|
||||
/** Why `url` is null, in words the picker shows. Null when the track is fine. */
|
||||
unavailable: string | null;
|
||||
};
|
||||
|
||||
export type QualityRung = { label: string; videoBitrate: number; maxWidth: number };
|
||||
|
||||
/** What `/_officer/items/{id}/playback` answers — the sidecar's own shape, not Jellyfin's. */
|
||||
export type PlaybackPlan = {
|
||||
playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode';
|
||||
url: string;
|
||||
/** A direct file seeks in the browser; a live transcode does not, and the player restarts it at an offset. */
|
||||
seekable: boolean;
|
||||
hlsUrl: string | null;
|
||||
playSessionId: string | null;
|
||||
mediaSourceId: string | null;
|
||||
mediaSource: MediaSource;
|
||||
startSeconds: number;
|
||||
runtimeSeconds: number | null;
|
||||
audio: AudioTrack[];
|
||||
subtitles: SubtitleTrack[];
|
||||
/** Echoed back so the picker shows what is actually playing, not what was last clicked. */
|
||||
audioStreamIndex: number | null;
|
||||
/** Null means Auto — the server's own copy-or-encode decision rather than a rung the owner picked. */
|
||||
maxStreamingBitrate: number | null;
|
||||
qualityRungs: QualityRung[];
|
||||
};
|
||||
|
||||
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the escape hatch
|
||||
// userMiddleware already supports for <img>/<audio>/<video>, and the one the music and photos apps use. These
|
||||
// point at `/_jf`, the authenticated byte pass-through, which keeps Jellyfin's own path shapes: an HLS
|
||||
// playlist references its segments relatively, so any renaming here would mean rewriting m3u8 bodies.
|
||||
|
||||
export const JELLYFIN_BYTES = '/api/jellyfin/_jf';
|
||||
|
||||
const withToken = (url: string, token: string | null) =>
|
||||
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
|
||||
|
||||
/** A `/_jf/...` path from a playback plan, made absolute and authenticated for a `<video>` element. */
|
||||
export const streamUrl = (path: string, token: string | null): string => withToken(`/api/jellyfin${path}`, token);
|
||||
|
||||
type ImageKind = 'Primary' | 'Backdrop' | 'Thumb' | 'Logo';
|
||||
|
||||
type ImageOptions = { tag?: string | null; maxWidth?: number; maxHeight?: number; index?: number };
|
||||
|
||||
export function imageUrl(id: string, kind: ImageKind, token: string | null, options: ImageOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
// The tag is the image's content hash. Including it is what makes these URLs safely cacheable forever —
|
||||
// without it, a replaced poster keeps showing the old one until the browser cache expires.
|
||||
if (options.tag) params.set('tag', options.tag);
|
||||
if (options.maxWidth) params.set('maxWidth', String(options.maxWidth));
|
||||
if (options.maxHeight) params.set('maxHeight', String(options.maxHeight));
|
||||
params.set('quality', '90');
|
||||
const suffix = options.index != null ? `/${options.index}` : '';
|
||||
return withToken(`${JELLYFIN_BYTES}/Items/${id}/Images/${kind}${suffix}?${params}`, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* The poster for a grid tile, falling back the way Jellyfin's own clients do.
|
||||
*
|
||||
* An episode usually has no poster of its own — it has a Thumb, and its series has the Primary. Falling back
|
||||
* to the series poster is what stops a season view from being a wall of grey rectangles.
|
||||
*/
|
||||
export function posterUrl(item: JellyItem, token: string | null, maxWidth = 400): string | null {
|
||||
const primary = item.ImageTags?.Primary;
|
||||
if (primary) return imageUrl(item.Id, 'Primary', token, { tag: primary, maxWidth });
|
||||
if (item.SeriesPrimaryImageTag && item.SeriesId) {
|
||||
return imageUrl(item.SeriesId, 'Primary', token, { tag: item.SeriesPrimaryImageTag, maxWidth });
|
||||
}
|
||||
const thumb = item.ImageTags?.Thumb;
|
||||
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The wide still for a Continue Watching card: the episode's own thumb, else a backdrop, else the poster. */
|
||||
export function thumbUrl(item: JellyItem, token: string | null, maxWidth = 520): string | null {
|
||||
const thumb = item.ImageTags?.Thumb;
|
||||
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
|
||||
const backdrop = item.BackdropImageTags?.[0];
|
||||
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
|
||||
const parentBackdrop = item.ParentBackdropImageTags?.[0];
|
||||
if (parentBackdrop && item.ParentBackdropItemId) {
|
||||
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
|
||||
}
|
||||
return posterUrl(item, token, maxWidth);
|
||||
}
|
||||
|
||||
export function backdropUrl(item: JellyItem, token: string | null, maxWidth = 1600): string | null {
|
||||
const backdrop = item.BackdropImageTags?.[0];
|
||||
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
|
||||
const parentBackdrop = item.ParentBackdropImageTags?.[0];
|
||||
if (parentBackdrop && item.ParentBackdropItemId) {
|
||||
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const TICKS_PER_SECOND = 10_000_000;
|
||||
|
||||
export const ticksToSeconds = (ticks: number | null | undefined): number => (ticks ? ticks / TICKS_PER_SECOND : 0);
|
||||
|
||||
/** Runtime as "1h 42m" / "48m". Blank rather than "0m" when unknown — a wrong number reads as a real one. */
|
||||
export function formatRuntime(ticks: number | null | undefined): string {
|
||||
const total = Math.round(ticksToSeconds(ticks) / 60);
|
||||
if (!total) return '';
|
||||
const hours = Math.floor(total / 60);
|
||||
const minutes = total % 60;
|
||||
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
/** Clock time for the player scrubber — "12:34" under an hour, "1:02:03" over it. */
|
||||
export function formatClock(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const total = Math.floor(seconds);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export const formatShortDate = (iso: string | null | undefined): string =>
|
||||
iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
|
||||
|
||||
/** How far through an item is, 0–100. Jellyfin sends this only sometimes, so it is also derived. */
|
||||
export function progressPercent(item: JellyItem): number {
|
||||
const data = item.UserData;
|
||||
if (!data) return 0;
|
||||
if (typeof data.PlayedPercentage === 'number') return Math.min(100, Math.max(0, data.PlayedPercentage));
|
||||
const position = ticksToSeconds(data.PlaybackPositionTicks);
|
||||
const runtime = ticksToSeconds(item.RunTimeTicks);
|
||||
return runtime > 0 && position > 0 ? Math.min(100, (position / runtime) * 100) : 0;
|
||||
}
|
||||
|
||||
/** "S2:E5 · Episode name" for an episode, the plain name for anything else. */
|
||||
export function episodeLabel(item: JellyItem): string {
|
||||
if (item.Type !== 'Episode') return item.Name ?? 'Untitled';
|
||||
const season = item.ParentIndexNumber != null ? `S${item.ParentIndexNumber}` : '';
|
||||
const episode = item.IndexNumber != null ? `E${item.IndexNumber}` : '';
|
||||
const number = [season, episode].filter(Boolean).join(':');
|
||||
return number ? `${number} · ${item.Name ?? ''}`.trim() : (item.Name ?? 'Untitled');
|
||||
}
|
||||
|
||||
/** The line under a title in a grid: the series for an episode, otherwise the year. */
|
||||
export function subtitleFor(item: JellyItem): string {
|
||||
if (item.Type === 'Episode') return item.SeriesName ?? '';
|
||||
if (item.Type === 'Season') return item.SeriesName ?? '';
|
||||
return item.ProductionYear ? String(item.ProductionYear) : '';
|
||||
}
|
||||
|
||||
/** Which grids get a 2:3 poster and which get a 16:9 still. Episodes are wide; everything else is tall. */
|
||||
export const isWideItem = (item: JellyItem): boolean => item.Type === 'Episode';
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { HomeResponse, ItemsResponse, JellyItem, PlaybackPlan } from './shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Reads for the /jellyfin panels. Everything goes through /api/jellyfin/_officer/*, the auth proxy in front
|
||||
// of the officer-jellyfin sidecar — the browser never learns the Jellyfin URL or its access token.
|
||||
//
|
||||
// One query key namespace, ['jellyfin'], and every mutation invalidates all of it. That is coarse on purpose:
|
||||
// marking an episode watched changes Continue Watching, Next Up, the season list and the item itself, and
|
||||
// enumerating those relationships would be four chances to forget one.
|
||||
|
||||
const KEY = 'jellyfin';
|
||||
|
||||
const query = (params: Record<string, string | number | boolean | null | undefined>): string => {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
};
|
||||
|
||||
/** Libraries, Continue Watching, Next Up and the newest thing per library — one round trip. */
|
||||
export function useJellyfinHome(enabled = true) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'home'],
|
||||
queryFn: () => get<HomeResponse>('/jellyfin/_officer/home'),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Just the libraries — what the nav lists. Cheap enough to keep separate from the whole home payload. */
|
||||
export function useJellyfinViews(enabled = true) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'views'],
|
||||
queryFn: () => get<{ views: JellyItem[] }>('/jellyfin/_officer/views'),
|
||||
enabled,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type BrowseFilter = {
|
||||
parentId?: string;
|
||||
includeItemTypes?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'Ascending' | 'Descending';
|
||||
startIndex?: number;
|
||||
limit?: number;
|
||||
filters?: string;
|
||||
isFavorite?: boolean;
|
||||
recursive?: boolean;
|
||||
collapseBoxSetItems?: boolean;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
export function useJellyfinItems(filter: BrowseFilter, enabled = true) {
|
||||
const { get } = useClient();
|
||||
const qs = query(filter);
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'items', qs],
|
||||
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/items${qs}`),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useJellyfinItem(id: string | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'item', id],
|
||||
queryFn: () => get<JellyItem>(`/jellyfin/_officer/items/${id}`),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSeasons(seriesId: string | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'seasons', seriesId],
|
||||
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/shows/${seriesId}/seasons`),
|
||||
enabled: !!seriesId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEpisodes(seriesId: string | null, seasonId: string | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'episodes', seriesId, seasonId],
|
||||
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/shows/${seriesId}/episodes${query({ seasonId })}`),
|
||||
enabled: !!seriesId && !!seasonId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useJellyfinSearch(term: string, enabled = true) {
|
||||
const { get } = useClient();
|
||||
const trimmed = term.trim();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'search', trimmed],
|
||||
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/search${query({ q: trimmed })}`),
|
||||
enabled: enabled && trimmed.length > 1,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Watched and favourite marks. Both invalidate everything, because both change Next Up and Resume. */
|
||||
export function useItemFlags() {
|
||||
const { post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
|
||||
|
||||
const setPlayed = useMutation({
|
||||
mutationFn: ({ id, played }: { id: string; played: boolean }) =>
|
||||
played ? post(`/jellyfin/_officer/items/${id}/played`, {}) : del(`/jellyfin/_officer/items/${id}/played`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setFavorite = useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? post(`/jellyfin/_officer/items/${id}/favorite`, {}) : del(`/jellyfin/_officer/items/${id}/favorite`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { setPlayed, setFavorite };
|
||||
}
|
||||
|
||||
// ── Playback ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlaybackRequest = {
|
||||
id: string;
|
||||
startSeconds: number;
|
||||
mediaSourceId?: string;
|
||||
audioStreamIndex?: number;
|
||||
/** A rung the owner picked. Omitted means Auto — the server decides whether it can copy the video. */
|
||||
maxStreamingBitrate?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Negotiate a stream. A mutation rather than a query on purpose: it has a side effect on the server —
|
||||
* `AutoOpenLiveStream` starts an ffmpeg process for a transcode — so it must never be replayed by a refetch,
|
||||
* a window focus, or React's strict-mode double render.
|
||||
*/
|
||||
export function usePlaybackPlan() {
|
||||
const { post } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, startSeconds, mediaSourceId, audioStreamIndex, maxStreamingBitrate }: PlaybackRequest) =>
|
||||
post<PlaybackPlan>(`/jellyfin/_officer/items/${id}/playback${query({ startSeconds })}`, {
|
||||
mediaSourceId,
|
||||
audioStreamIndex,
|
||||
maxStreamingBitrate,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type PlaystateReport = {
|
||||
ItemId: string;
|
||||
PlaySessionId?: string | null;
|
||||
MediaSourceId?: string | null;
|
||||
PositionTicks: number;
|
||||
IsPaused?: boolean;
|
||||
CanSeek?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tell Jellyfin where playback is, so resume works and the server knows a session is alive.
|
||||
*
|
||||
* `fire-and-forget` is deliberate: a dropped progress ping costs a few seconds of resume accuracy, and
|
||||
* surfacing it as an error over the video would be a worse trade. The stop report is the one that matters,
|
||||
* and it is also the one most likely to race a page unload — hence `sendBeacon`-shaped semantics (no
|
||||
* response read, no await from the caller).
|
||||
*/
|
||||
export function usePlaystateReporter() {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const report = (action: 'playing' | 'progress' | 'stopped', body: PlaystateReport) => {
|
||||
void post(`/jellyfin/_officer/sessions/${action}`, body).catch(() => undefined);
|
||||
// Only a stop changes what the shelves should show. Pinging progress every ten seconds and invalidating
|
||||
// each time would refetch the whole home screen underneath a playing video.
|
||||
if (action === 'stopped') void qc.invalidateQueries({ queryKey: [KEY] });
|
||||
};
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ── Servers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Jellyfin instances and the account each is signed in as are the owner's to set from /jellyfin/settings.
|
||||
// It is a REGISTRY — any number of labelled servers, one selected — because this machine genuinely runs
|
||||
// several. The access token is WRITE-ONLY across this boundary: a server carries its label, URL, account name
|
||||
// and whether it is selected, and has no field that could carry the token back to the browser.
|
||||
|
||||
export type JellyfinHealth = {
|
||||
ok: boolean;
|
||||
/** False only when no server is stored. It is what separates "set this up" from "this used to work". */
|
||||
configured?: boolean;
|
||||
/** Label of the selected server, so a failure names which one did not answer. */
|
||||
server?: string;
|
||||
serverName?: string | null;
|
||||
version?: string | null;
|
||||
ms?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type JellyfinServerRow = {
|
||||
id: number;
|
||||
label: string;
|
||||
url: string;
|
||||
jellyfinUsername: string | null;
|
||||
serverName: string | null;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type JellyfinServers = { configured: boolean; activeId: number | null; servers: JellyfinServerRow[] };
|
||||
|
||||
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
|
||||
export function jellyfinErrorMessage(err: unknown): string {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return raw.slice(0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health, including its failure bodies.
|
||||
*
|
||||
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
||||
* the UI most needs to tell apart — 503 not configured and 502 configured-but-broken. Both carry a JSON body,
|
||||
* so the throw is turned back into the answer rather than an error state.
|
||||
*/
|
||||
export function useJellyfinHealth() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'health'],
|
||||
queryFn: async (): Promise<JellyfinHealth> => {
|
||||
try {
|
||||
return await get<JellyfinHealth>('/jellyfin/_health');
|
||||
} catch (err) {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(raw) as JellyfinHealth;
|
||||
if (body && body.ok === false) return body;
|
||||
} catch {
|
||||
/* not the sidecar's body */
|
||||
}
|
||||
}
|
||||
// Anything else — the platform proxy, auth, the sidecar being down — is a configured server that is
|
||||
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||
return { ok: false, configured: true, error: jellyfinErrorMessage(err) };
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useJellyfinServers() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'servers'],
|
||||
queryFn: () => get<JellyfinServers>('/jellyfin/_config'),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type AddServerInput = { label: string; url: string; username: string; password: string };
|
||||
export type EditServerInput = { id: number; label?: string; url?: string; username?: string; password?: string };
|
||||
|
||||
export function useJellyfinServerActions() {
|
||||
const { get, post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
// Adding, editing, switching and removing all change what every other query here can even answer — a switch
|
||||
// in particular changes the answer to all of them without changing any of their inputs.
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (input: AddServerInput) => post<{ server: JellyfinServerRow }>('/jellyfin/_config', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const edit = useMutation({
|
||||
mutationFn: ({ id, ...rest }: EditServerInput) =>
|
||||
patch<{ server: JellyfinServerRow }>(`/jellyfin/_config/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<JellyfinServers>(`/jellyfin/_config/${id}/activate`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del<JellyfinServers>(`/jellyfin/_config/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// GET — probes one server and reports whether it answered. It does not switch to it (that is
|
||||
// `activate`) and writes nothing. useMutation is still right: it is fired by a button, not rendered.
|
||||
const test = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
get<{ ok: boolean; version?: string | null; serverName?: string | null; ms: number }>(
|
||||
`/jellyfin/_config/${id}/test`,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { add, edit, activate, remove, test };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { DEFAULT_JELLYFIN_SECTION, isJellyfinSection, type JellyfinSectionId } from './shared';
|
||||
|
||||
// The URL names the section; nothing else does. JellyfinScreen redirects anything unrecognised, so the
|
||||
// fallback here only covers the instant before that lands.
|
||||
|
||||
export function useJellyfinSection(): JellyfinSectionId {
|
||||
const { section } = useParams();
|
||||
return isJellyfinSection(section) ? section : DEFAULT_JELLYFIN_SECTION;
|
||||
}
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
# Vendored hls.js
|
||||
|
||||
`hls.mjs` is **hls.js 1.6.16**, checked in rather than installed.
|
||||
|
||||
## Why it is here and not in `package.json`
|
||||
|
||||
Chromium cannot play an HLS playlist natively, so seeking inside a live Jellyfin transcode needs a player
|
||||
library. `bunfig.toml` freezes installs (`frozenLockfile = true`) specifically so that adding a package is a
|
||||
deliberate act with a reviewed lockfile diff — see `platform/CLAUDE.md` → "Installs are frozen", and the
|
||||
2026-08-04 npm cache-package worm that prompted it.
|
||||
|
||||
Vendoring makes the trade explicit instead of implicit:
|
||||
|
||||
- **No install-time code.** A committed file has no `preinstall`/`postinstall` hook, which is the exact vector
|
||||
that compromise used. hls.js's own tarball has no install script either — but a future version of it, or of
|
||||
anything it might later depend on, could.
|
||||
- **No transitive tree.** hls.js 1.6.16 publishes `dependencies: {}`, `peerDependencies: {}` and
|
||||
`optionalDependencies: {}`, so nothing is being skipped by not resolving it. Its *build-time* devDependencies
|
||||
do reach the affected packages (eslint 8 → file-entry-cache → flat-cache → keyv), but this artifact was
|
||||
published 2026-04-13, months before those versions existed.
|
||||
- **It is reviewable.** One unminified file in the diff. That is the point; do not replace it with a minified
|
||||
build to save bytes.
|
||||
|
||||
The upstream `.d.ts` is *not* vendored — it imports a tree of internal type modules, which would undo the one
|
||||
file rule. `hls.d.mts` next to it declares the API this player actually uses, by hand.
|
||||
|
||||
## Provenance
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Package | `hls.js@1.6.16` |
|
||||
| Published | 2026-04-13 |
|
||||
| Tarball | `https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz` |
|
||||
| Tarball sha512 | `VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==` (matches the registry's published `dist.integrity`) |
|
||||
| Extracted from | `package/dist/hls.mjs` |
|
||||
| Local change | the trailing `//# sourceMappingURL=hls.mjs.map` line removed — the map is not vendored, and leaving the reference makes devtools 404 |
|
||||
| `hls.mjs` sha256 | `45a4bb55e5346bf9284fa7e46b6fac7d62dce5b90394dcba9747d471940fea6e` as published; `ca442f1f963ff66607a242cc7be227b243770d86bfc3251bf675f8a342dfe798` as committed here (the two differ only by that removed line) |
|
||||
| Licence | Apache-2.0 — `hls.LICENSE` |
|
||||
|
||||
## Updating it
|
||||
|
||||
```sh
|
||||
V=1.6.17
|
||||
curl -sO https://registry.npmjs.org/hls.js/-/hls.js-$V.tgz
|
||||
# compare the sha512 against `curl -s https://registry.npmjs.org/hls.js/$V | jq -r .dist.integrity`
|
||||
tar xzf hls.js-$V.tgz package/dist/hls.mjs package/LICENSE
|
||||
```
|
||||
|
||||
Copy `package/dist/hls.mjs` over this one, drop the `sourceMappingURL` line, update the table above, and read
|
||||
the diff. A version bump that arrives with a diff you did not read is the thing this directory exists to
|
||||
prevent.
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
|
||||
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
|
||||
|
||||
That work is also covered by the Apache 2 License, following copyright:
|
||||
Copyright (c) 2013-2015 Brightcove
|
||||
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
// Hand-written declarations for the vendored `hls.mjs`.
|
||||
//
|
||||
// Deliberately narrow: it declares the API this player actually calls, not hls.js's full surface. The upstream
|
||||
// `.d.ts` is not vendored because it pulls in a tree of internal type modules, and the point of vendoring was
|
||||
// to have ONE reviewable file. Anything added to the player that hls.js supports but this file does not gets
|
||||
// declared here, in the open, rather than cast away at the call site.
|
||||
|
||||
export type HlsErrorData = {
|
||||
fatal: boolean;
|
||||
type: string;
|
||||
details: string;
|
||||
reason?: string;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type HlsLevel = { height?: number; width?: number; bitrate?: number };
|
||||
|
||||
export type HlsConfig = {
|
||||
/** Where playback begins, in seconds. -1 means "the start", which is not the same as 0 for a live edge. */
|
||||
startPosition?: number;
|
||||
/** Called before `xhr.open`, so it may open the request itself and then set headers on it. */
|
||||
xhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
|
||||
enableWorker?: boolean;
|
||||
lowLatencyMode?: boolean;
|
||||
maxBufferLength?: number;
|
||||
maxMaxBufferLength?: number;
|
||||
backBufferLength?: number;
|
||||
};
|
||||
|
||||
declare class Hls {
|
||||
constructor(config?: HlsConfig);
|
||||
|
||||
static isSupported(): boolean;
|
||||
static readonly Events: {
|
||||
MANIFEST_PARSED: string;
|
||||
LEVEL_LOADED: string;
|
||||
ERROR: string;
|
||||
MEDIA_ATTACHED: string;
|
||||
};
|
||||
static readonly ErrorTypes: {
|
||||
NETWORK_ERROR: string;
|
||||
MEDIA_ERROR: string;
|
||||
KEY_SYSTEM_ERROR: string;
|
||||
MUX_ERROR: string;
|
||||
OTHER_ERROR: string;
|
||||
};
|
||||
|
||||
loadSource(url: string): void;
|
||||
attachMedia(media: HTMLMediaElement): void;
|
||||
detachMedia(): void;
|
||||
startLoad(startPosition?: number): void;
|
||||
stopLoad(): void;
|
||||
recoverMediaError(): void;
|
||||
destroy(): void;
|
||||
|
||||
on(event: string, listener: (event: string, data: HlsErrorData) => void): void;
|
||||
off(event: string, listener?: (event: string, data: HlsErrorData) => void): void;
|
||||
|
||||
readonly levels: HlsLevel[];
|
||||
currentLevel: number;
|
||||
}
|
||||
|
||||
export default Hls;
|
||||
Vendored
+36705
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user