jellyfin panel app: browse, detail and player

adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.

the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:16:45 +00:00
co-authored by Claude Opus 5
parent 904edefd62
commit a894d64cb3
27 changed files with 2206 additions and 16 deletions
+2
View File
@@ -48,6 +48,8 @@ export function App() {
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} /> <Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} /> <Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} /> <Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} /> <Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} /> <Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} /> <Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
@@ -0,0 +1,60 @@
import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /jellyfin uses the Workspace/Panel system: the section and library nav (jellyfin-nav) on the left, the
// section view (jellyfin-view) on the right. Both talk to the officer-jellyfin sidecar through the
// /api/jellyfin auth proxy; the Jellyfin URL and its access token live in the sidecar and the browser never
// sees either.
//
// Video only — the owner's audio is Officer's own /music player, and the sidecar already filters music
// libraries out of everything this screen can ask for.
//
// The open section is :section in the URL; which library, which item and what is playing are in the query
// string. Nothing about "what is open" lives in a panel channel.
const ALLOWED_APP_TYPES = new Set<string | null>(['jellyfin-nav', 'jellyfin-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'jellyfin-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const JellyfinScreen = () => {
const { section } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/jellyfin', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /jellyfin, or a section that doesn't exist, canonicalises rather than rendering a default behind a
// URL that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
if (!isJellyfinSection(section)) {
return <Navigate to={jellyfinSectionPath(DEFAULT_JELLYFIN_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</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 @@
export * from './JellyfinScreen';
@@ -141,6 +141,7 @@ import {
Images, Images,
CalendarDays, CalendarDays,
Contact, Contact,
Clapperboard,
} from 'lucide-react'; } from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [ export const ALL_DOCK_ITEMS: DockItem[] = [
@@ -152,6 +153,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' }, { label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' }, { label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' }, { label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' }, { label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' }, { label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
@@ -16,6 +16,7 @@ export * from './Music';
export * from './Soulseek'; export * from './Soulseek';
export * from './Headscale'; export * from './Headscale';
export * from './Photos'; export * from './Photos';
export * from './Jellyfin';
export * from './Transmission'; export * from './Transmission';
export * from './Invoices'; export * from './Invoices';
export * from './Wallet'; export * from './Wallet';
@@ -19,6 +19,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' }, { match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' }, { match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' }, { match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' }, { match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
+1 -1
View File
@@ -29,7 +29,7 @@ import { getConfig, probe } from './upstream';
// GET /_officer/items?parentId=… browse grid (allow-listed filters, poster fields always on) // GET /_officer/items?parentId=… browse grid (allow-listed filters, poster fields always on)
// GET /_officer/items/:id full detail, incl. MediaSources // GET /_officer/items/:id full detail, incl. MediaSources
// GET /_officer/items/:id/similar // GET /_officer/items/:id/similar
// POST /_officer/items/:id/playback negotiate → { playMethod, url, isHls, playSessionId } // POST /_officer/items/:id/playback negotiate → { playMethod, url, seekable, hlsUrl, playSessionId }
// POST|DEL /_officer/items/:id/played watched mark // POST|DEL /_officer/items/:id/played watched mark
// POST|DEL /_officer/items/:id/favorite // POST|DEL /_officer/items/:id/favorite
// GET /_officer/shows/:id/seasons // GET /_officer/shows/:id/seasons
+13 -4
View File
@@ -5,9 +5,14 @@
// browser too generously and playback dies silently on an unsupported codec; too conservatively and every // browser too generously and playback dies silently on an unsupported codec; too conservatively and every
// file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container). // file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container).
// //
// It deliberately describes what a modern Chromium/WebKit `<video>` plus hls.js can actually play, which is // It deliberately describes what a bare Chromium/WebKit `<video>` can play with no media library loaded, which
// narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any // is narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any
// browser, so mkv is the common case that has to go out as HLS even when its streams are already h264/aac. // browser, so mkv is the common case that has to be remuxed even when its streams are already h264/aac.
//
// Both transcoding profiles are declared, and the ORDER is load-bearing. HLS is first, so `TranscodingUrl` in
// the PlaybackInfo response stays an m3u8 the player carries but does not use (see routes.ts → `hlsUrl`). The
// progressive http profile below is what our player actually requests, and it exists so the server accepts
// that request; without it, `/Videos/{id}/stream.mp4?static=false` is refused as outside the profile.
// //
// Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed // Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed
// target (our own web player), and a profile assembled from feature detection in the browser is the classic // target (our own web player), and a profile assembled from feature detection in the browser is the classic
@@ -43,12 +48,16 @@ export const DEVICE_PROFILE = {
BreakOnNonKeyFrames: true, BreakOnNonKeyFrames: true,
}, },
{ {
// Progressive mp4 over plain http — what our player requests. `Streaming`, not `Static`: `Static` means
// "the whole file, transcoded up front", which for a live seek-by-restart player is the wrong contract
// and is refused for a stream opened at an offset.
Container: 'mp4', Container: 'mp4',
Type: 'Video', Type: 'Video',
AudioCodec: 'aac', AudioCodec: 'aac',
VideoCodec: 'h264', VideoCodec: 'h264',
Context: 'Static', Context: 'Streaming',
Protocol: 'http', Protocol: 'http',
MaxAudioChannels: '2',
}, },
], ],
+63 -11
View File
@@ -190,8 +190,11 @@ async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
type MediaSource = { type MediaSource = {
Id?: string; Id?: string;
Container?: string;
RunTimeTicks?: number;
SupportsDirectPlay?: boolean; SupportsDirectPlay?: boolean;
SupportsDirectStream?: boolean; SupportsDirectStream?: boolean;
DirectStreamUrl?: string;
TranscodingUrl?: string; TranscodingUrl?: string;
}; };
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null }; type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
@@ -213,13 +216,51 @@ function sanitizeUpstreamPath(raw: string): string {
return `${path}${qs ? `?${qs}` : ''}`; return `${path}${qs ? `?${qs}` : ''}`;
} }
/**
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
*
* This is the deliberate fallback instead of HLS, and the reason is a dependency this repo does not have:
* Chromium cannot play an HLS playlist natively, so the m3u8 path needs hls.js, and `bunfig.toml` freezes
* installs precisely so a new package is a considered act rather than a side effect of a feature. A
* progressive stream needs nothing.
*
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub.
*
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
* machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU.
*/
function progressivePath(id: string, source: MediaSource, playSessionId: string | null, startSeconds: number): string {
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
static: 'false',
container: 'mp4',
mediaSourceId: source.Id,
playSessionId: playSessionId ?? undefined,
videoCodec: 'h264',
audioCodec: 'aac',
audioBitrate: '192000',
maxAudioChannels: '2',
allowVideoStreamCopy: 'true',
allowAudioStreamCopy: 'true',
// No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which
// throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles
// are a deliberate later feature, chosen per-item, not a default that quietly costs that much.
startTimeTicks: startSeconds > 0 ? String(Math.round(startSeconds * TICKS_PER_SECOND)) : undefined,
})}`;
}
/** /**
* Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into * Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
* one URL the player can use. * one URL the player can use.
* *
* The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on * The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
* the fly, still `/Videos/{id}/stream`) and transcode (HLS). They are reported explicitly rather than * the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
* inferred, because "why is my CPU pinned" is a question the UI should be able to answer. * because "why is my CPU pinned" is a question the UI should be able to answer.
*
* `hlsUrl` is returned alongside whenever Jellyfin offered one. Nothing consumes it yet — it is what the
* player switches to the day hls.js is added on purpose, and it costs nothing to carry until then.
*/ */
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> { async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0'); const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
@@ -260,16 +301,22 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
if (!source) return bad('the server returned no playable source for this item', 502); if (!source) return bad('the server returned no playable source for this item', 502);
const playSessionId = info.PlaySessionId ?? null; const playSessionId = info.PlaySessionId ?? null;
const direct = source.SupportsDirectPlay || source.SupportsDirectStream; const direct = !!(source.SupportsDirectPlay || source.SupportsDirectStream);
const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
static: 'true',
mediaSourceId: source.Id,
playSessionId: playSessionId ?? undefined,
})}`;
const path = direct const path = direct
? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({ ? source.SupportsDirectPlay
static: 'true', ? staticPath
mediaSourceId: source.Id, : (source.DirectStreamUrl && sanitizeUpstreamPath(source.DirectStreamUrl)) || staticPath
playSessionId: playSessionId ?? undefined, : // A `TranscodingUrl` is Jellyfin agreeing to transcode. We ask for the same thing progressively
})}` // instead of taking its HLS URL, but its absence still means "refused", so it stays the signal.
: source.TranscodingUrl source.TranscodingUrl
? sanitizeUpstreamPath(source.TranscodingUrl) ? progressivePath(id, source, playSessionId, startSeconds)
: null; : null;
if (!path) { if (!path) {
@@ -280,11 +327,16 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode', playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
// Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest. // Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
url: `/_jf${path}`, url: `/_jf${path}`,
isHls: !direct, // A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
seekable: direct,
// Carried, not used. The day hls.js is a deliberate dependency, the player switches to this.
hlsUrl: source.TranscodingUrl ? `/_jf${sanitizeUpstreamPath(source.TranscodingUrl)}` : null,
playSessionId, playSessionId,
mediaSourceId: source.Id ?? null, mediaSourceId: source.Id ?? null,
mediaSource: source, mediaSource: source,
startSeconds, startSeconds,
runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
}); });
} }
@@ -11,6 +11,7 @@ import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek'; import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale'; import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as photosMetas } from '../apps/Photos'; import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission'; import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as walletMetas } from '../apps/Wallet';
@@ -33,6 +34,7 @@ const apps = [
...soulseekMetas, ...soulseekMetas,
...headscaleMetas, ...headscaleMetas,
...photosMetas, ...photosMetas,
...jellyfinMetas,
...transmissionMetas, ...transmissionMetas,
...invoicesMetas, ...invoicesMetas,
...walletMetas, ...walletMetas,
@@ -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-emerald-500" />
) : (
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
)}
<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-violet-500/15 text-violet-400">
<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-red-500' : ''}`} />
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-violet-500/15 text-violet-400 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,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,230 @@
import type { PlaybackPlan } from './shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Loader2, Maximize2, Pause, Play, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import {
closeHref,
episodeLabel,
formatClock,
PLAY_PARAM,
streamUrl,
TICKS_PER_SECOND,
ticksToSeconds,
} from './shared';
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
// The player — a full-panel overlay opened by `?play=<id>`.
//
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: a transcoded stream has no length and no byte
// ranges, so the browser cannot seek it. `plan.seekable` says which world we are in, and the two are handled
// differently rather than pretended to be the same:
//
// direct play / direct stream → a real file. Native controls, native seeking, `currentTime` is the position.
// transcode → a live progressive mp4 starting at an offset. Seeking means asking the
// server for a NEW stream at a new offset, so the position is
// `offset + currentTime` and the scrubber is ours.
//
// Every progress report uses that same computed position, which is why resume points are correct in both
// modes instead of being wrong by the offset in one of them.
const PROGRESS_INTERVAL_MS = 10_000;
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 videoRef = useRef<HTMLVideoElement>(null);
const [plan, setPlan] = useState<PlaybackPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [position, setPosition] = useState(0);
const [paused, setPaused] = useState(false);
// 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 duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
const sendReport = useCallback(
(action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
const current = planRef.current;
if (!current) return;
report(action, {
ItemId: id,
PlaySessionId: current.playSessionId,
MediaSourceId: current.mediaSourceId,
PositionTicks: Math.max(0, Math.round(positionRef.current * TICKS_PER_SECOND)),
IsPaused: isPaused,
CanSeek: current.seekable,
});
},
[id, report],
);
/** Open (or re-open) the stream at an offset. A seek in transcode mode is exactly this call. */
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 negotiate.mutateAsync({ id, startSeconds });
planRef.current = next;
positionRef.current = next.startSeconds;
setPlan(next);
setPosition(next.startSeconds);
} catch (err) {
setError((err as { message?: string } | null)?.message ?? 'Playback could not be started');
}
},
[id, negotiate, 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]);
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
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]);
const onTimeUpdate = () => {
const video = videoRef.current;
if (!video || !plan) return;
// In transcode mode the element's clock starts at zero for a stream that begins mid-film.
const next = plan.seekable ? video.currentTime : plan.startSeconds + video.currentTime;
positionRef.current = next;
setPosition(next);
};
const seek = (seconds: number) => {
const video = videoRef.current;
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
if (plan?.seekable && video) {
video.currentTime = clamped;
return;
}
void open(clamped);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) void video.play().catch(() => undefined);
else video.pause();
};
const close = closeHref(pathname, params, PLAY_PARAM);
const title = item ? episodeLabel(item) : '';
return (
<div className="absolute inset-0 z-30 flex flex-col bg-black">
<div className="flex items-center gap-2 px-3 py-2 text-white">
<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="relative flex-1 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={plan.url}
ref={videoRef}
src={streamUrl(plan.url, token)}
className="h-full w-full"
autoPlay
// A direct file is seekable, so the browser's own controls are correct and better than ours.
// A live transcode is not, and its controls would show a scrubber that does nothing.
controls={plan.seekable}
onTimeUpdate={onTimeUpdate}
onPlay={() => {
setPaused(false);
sendReport('playing');
}}
onPause={() => {
setPaused(true);
sendReport('progress', true);
}}
onEnded={() => sendReport('stopped')}
onError={() => setError('The stream stopped unexpectedly.')}
/>
) : (
<div className="flex h-full items-center justify-center text-white/60">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
{plan && !plan.seekable && !error && (
<div className="flex items-center gap-3 px-3 py-2 text-white">
<button type="button" onClick={togglePlay} aria-label={paused ? 'Play' : 'Pause'} className="shrink-0">
{paused ? <Play className="h-4 w-4" fill="currentColor" /> : <Pause className="h-4 w-4" />}
</button>
<span className="shrink-0 text-[11px] tabular-nums">{formatClock(position)}</span>
<input
type="range"
min={0}
max={Math.max(1, Math.floor(duration))}
value={Math.floor(position)}
// `onChange` fires on every pixel of a drag, and each one would start a transcode. Committing on
// release is what makes a scrub cost one ffmpeg instead of forty.
onChange={(ev) => setPosition(Number(ev.target.value))}
onMouseUp={(ev) => seek(Number((ev.target as HTMLInputElement).value))}
onTouchEnd={(ev) => seek(Number((ev.target as HTMLInputElement).value))}
onKeyUp={(ev) => seek(Number((ev.target as HTMLInputElement).value))}
className="h-1 flex-1 accent-primary"
aria-label="Seek"
/>
<span className="shrink-0 text-[11px] tabular-nums">{formatClock(duration)}</span>
<button
type="button"
onClick={() => void videoRef.current?.requestFullscreen().catch(() => undefined)}
aria-label="Fullscreen"
className="shrink-0"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,19 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Clapperboard, PanelLeft } from 'lucide-react';
import { JellyfinNav } from './JellyfinNav';
import { JellyfinView } from './JellyfinView';
import { JellyfinViewHeader } from './JellyfinViewHeader';
export { JellyfinNav, JellyfinView };
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,
},
];
@@ -0,0 +1,300 @@
// 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[] }[];
};
/** 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;
};
// ── 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, 0100. 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,314 @@
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 };
/**
* 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 }: PlaybackRequest) =>
post<PlaybackPlan>(`/jellyfin/_officer/items/${id}/playback${query({ startSeconds })}`, {
mediaSourceId,
audioStreamIndex,
}),
});
}
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 { 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,
});
const test = useMutation({
mutationFn: (id: number) =>
post<{ 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;
}
+4
View File
@@ -35,6 +35,10 @@ export type { HeadscaleSectionId } from './apps/Headscale/shared';
export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared'; export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared';
export type { PhotosSectionId } from './apps/Photos/shared'; export type { PhotosSectionId } from './apps/Photos/shared';
// Same for /jellyfin.
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
// Same for /transmission. // Same for /transmission.
export { export {
DEFAULT_TRANSMISSION_SECTION, DEFAULT_TRANSMISSION_SECTION,