jellyfin panel app: browse, detail and player

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:16:45 +00:00
co-authored by Claude Opus 5
parent 904edefd62
commit a894d64cb3
27 changed files with 2206 additions and 16 deletions
@@ -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';