From a894d64cb3773ff32e63e16eb7242c7ce02e9fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 18:16:45 +0000 Subject: [PATCH] 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 --- src/apps/officer-web/App.tsx | 2 + .../Dashboard/Jellyfin/JellyfinScreen.tsx | 60 ++++ .../Dashboard/Jellyfin/defaultLayout.ts | 11 + .../Screens/Dashboard/Jellyfin/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 2 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + src/servers/sidecar/jellyfin/index.ts | 2 +- src/servers/sidecar/jellyfin/profile.ts | 17 +- src/servers/sidecar/jellyfin/routes.ts | 74 +++- .../src/AppRegistry/AppRegistry.tsx | 2 + .../src/apps/Jellyfin/ConnectionSection.tsx | 331 ++++++++++++++++++ .../src/apps/Jellyfin/HomeSection.tsx | 57 +++ .../src/apps/Jellyfin/ItemDetail.tsx | 252 +++++++++++++ .../src/apps/Jellyfin/JellyfinNav.tsx | 103 ++++++ .../src/apps/Jellyfin/JellyfinView.tsx | 57 +++ .../src/apps/Jellyfin/JellyfinViewHeader.tsx | 17 + .../src/apps/Jellyfin/LibrarySection.tsx | 114 ++++++ .../src/apps/Jellyfin/MediaCard.tsx | 106 ++++++ .../src/apps/Jellyfin/SearchSection.tsx | 54 +++ .../src/apps/Jellyfin/ServerSwitcher.tsx | 81 +++++ .../src/apps/Jellyfin/VideoPlayer.tsx | 230 ++++++++++++ .../officerdev/src/apps/Jellyfin/index.ts | 19 + .../officerdev/src/apps/Jellyfin/shared.ts | 300 ++++++++++++++++ .../src/apps/Jellyfin/useJellyfinData.ts | 314 +++++++++++++++++ .../src/apps/Jellyfin/useJellyfinSection.ts | 10 + src/workspaces/officerdev/src/index.ts | 4 + 27 files changed, 2206 insertions(+), 16 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/ConnectionSection.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/HomeSection.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/ItemDetail.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/JellyfinNav.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/JellyfinView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/JellyfinViewHeader.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/LibrarySection.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/MediaCard.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/SearchSection.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/ServerSwitcher.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/VideoPlayer.tsx create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/shared.ts create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/useJellyfinData.ts create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/useJellyfinSection.ts 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 `