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:
@@ -48,6 +48,8 @@ export function App() {
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" 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/:section" element={<Dashboard.TransmissionScreen />} />
|
||||
<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,
|
||||
CalendarDays,
|
||||
Contact,
|
||||
Clapperboard,
|
||||
} from 'lucide-react';
|
||||
|
||||
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: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||
{ 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: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from './Music';
|
||||
export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './Photos';
|
||||
export * from './Jellyfin';
|
||||
export * from './Transmission';
|
||||
export * from './Invoices';
|
||||
export * from './Wallet';
|
||||
|
||||
@@ -19,6 +19,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ 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('/headscale'), title: 'Headscale' },
|
||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||
|
||||
@@ -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/:id full detail, incl. MediaSources
|
||||
// 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/favorite
|
||||
// GET /_officer/shows/:id/seasons
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
// 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).
|
||||
//
|
||||
// It deliberately describes what a modern Chromium/WebKit `<video>` plus hls.js can actually play, which 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.
|
||||
// It deliberately describes what a bare Chromium/WebKit `<video>` can play with no media library loaded, which
|
||||
// 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 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
|
||||
// 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,
|
||||
},
|
||||
{
|
||||
// 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',
|
||||
Type: 'Video',
|
||||
AudioCodec: 'aac',
|
||||
VideoCodec: 'h264',
|
||||
Context: 'Static',
|
||||
Context: 'Streaming',
|
||||
Protocol: 'http',
|
||||
MaxAudioChannels: '2',
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -190,8 +190,11 @@ async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
|
||||
|
||||
type MediaSource = {
|
||||
Id?: string;
|
||||
Container?: string;
|
||||
RunTimeTicks?: number;
|
||||
SupportsDirectPlay?: boolean;
|
||||
SupportsDirectStream?: boolean;
|
||||
DirectStreamUrl?: string;
|
||||
TranscodingUrl?: string;
|
||||
};
|
||||
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
|
||||
@@ -213,13 +216,51 @@ function sanitizeUpstreamPath(raw: string): string {
|
||||
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
|
||||
* one URL the player can use.
|
||||
*
|
||||
* 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
|
||||
* inferred, because "why is my CPU pinned" is a question the UI should be able to answer.
|
||||
* the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
|
||||
* 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> {
|
||||
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);
|
||||
|
||||
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
|
||||
? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
|
||||
static: 'true',
|
||||
mediaSourceId: source.Id,
|
||||
playSessionId: playSessionId ?? undefined,
|
||||
})}`
|
||||
: source.TranscodingUrl
|
||||
? sanitizeUpstreamPath(source.TranscodingUrl)
|
||||
? source.SupportsDirectPlay
|
||||
? staticPath
|
||||
: (source.DirectStreamUrl && sanitizeUpstreamPath(source.DirectStreamUrl)) || staticPath
|
||||
: // 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
|
||||
? progressivePath(id, source, playSessionId, startSeconds)
|
||||
: null;
|
||||
|
||||
if (!path) {
|
||||
@@ -280,11 +327,16 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
|
||||
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.
|
||||
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,
|
||||
mediaSourceId: source.Id ?? null,
|
||||
mediaSource: source,
|
||||
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 headscaleMetas } from '../apps/Headscale';
|
||||
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
||||
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||
@@ -33,6 +34,7 @@ const apps = [
|
||||
...soulseekMetas,
|
||||
...headscaleMetas,
|
||||
...photosMetas,
|
||||
...jellyfinMetas,
|
||||
...transmissionMetas,
|
||||
...invoicesMetas,
|
||||
...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, 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,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;
|
||||
}
|
||||
@@ -35,6 +35,10 @@ export type { HeadscaleSectionId } from './apps/Headscale/shared';
|
||||
export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } 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.
|
||||
export {
|
||||
DEFAULT_TRANSMISSION_SECTION,
|
||||
|
||||
Reference in New Issue
Block a user