diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index d88b75bc..ba6ba6fc 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -48,6 +48,8 @@ export function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx
new file mode 100644
index 00000000..6f39e483
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx
@@ -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(['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('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 ;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts
new file mode 100644
index 00000000..2e6bd1d2
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts
@@ -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 },
+ ],
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx b/src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx
new file mode 100644
index 00000000..9c74ebaa
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx
@@ -0,0 +1 @@
+export * from './JellyfinScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index 6c834c74..d9ac72e6 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -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' },
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index 80f3a349..3fb10608 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -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';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 181ae9bc..2fd2f403 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -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' },
diff --git a/src/servers/sidecar/jellyfin/index.ts b/src/servers/sidecar/jellyfin/index.ts
index 35c85f03..86b4323b 100644
--- a/src/servers/sidecar/jellyfin/index.ts
+++ b/src/servers/sidecar/jellyfin/index.ts
@@ -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
diff --git a/src/servers/sidecar/jellyfin/profile.ts b/src/servers/sidecar/jellyfin/profile.ts
index 222f9dbe..54f2ceac 100644
--- a/src/servers/sidecar/jellyfin/profile.ts
+++ b/src/servers/sidecar/jellyfin/profile.ts
@@ -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 `