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="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||||
|
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||||
|
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||||
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
||||||
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
|
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { Navigate, useParams } from 'react-router';
|
||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
import { WorkspaceView, DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from 'officerdev';
|
||||||
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
|
// /jellyfin uses the Workspace/Panel system: the section and library nav (jellyfin-nav) on the left, the
|
||||||
|
// section view (jellyfin-view) on the right. Both talk to the officer-jellyfin sidecar through the
|
||||||
|
// /api/jellyfin auth proxy; the Jellyfin URL and its access token live in the sidecar and the browser never
|
||||||
|
// sees either.
|
||||||
|
//
|
||||||
|
// Video only — the owner's audio is Officer's own /music player, and the sidecar already filters music
|
||||||
|
// libraries out of everything this screen can ask for.
|
||||||
|
//
|
||||||
|
// The open section is :section in the URL; which library, which item and what is playing are in the query
|
||||||
|
// string. Nothing about "what is open" lives in a panel channel.
|
||||||
|
|
||||||
|
const ALLOWED_APP_TYPES = new Set<string | null>(['jellyfin-nav', 'jellyfin-view', null]);
|
||||||
|
|
||||||
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'jellyfin-view' };
|
||||||
|
}
|
||||||
|
const children = node.children.map((c) => {
|
||||||
|
const fixed = normalizeLayout(c.node);
|
||||||
|
return fixed === c.node ? c : { ...c, node: fixed };
|
||||||
|
});
|
||||||
|
const changed = children.some((c, i) => c !== node.children[i]);
|
||||||
|
return changed ? { ...node, children } : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JellyfinScreen = () => {
|
||||||
|
const { section } = useParams();
|
||||||
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/jellyfin', defaultLayout);
|
||||||
|
|
||||||
|
const workspace = useMemo(() => {
|
||||||
|
const fixed = normalizeLayout(rawWorkspace.value);
|
||||||
|
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||||
|
return { ...rawWorkspace, value: fixed };
|
||||||
|
}, [rawWorkspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||||
|
rawWorkspace.setValue(workspace.value);
|
||||||
|
}
|
||||||
|
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||||
|
|
||||||
|
// Bare /jellyfin, or a section that doesn't exist, canonicalises rather than rendering a default behind a
|
||||||
|
// URL that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
|
||||||
|
if (!isJellyfinSection(section)) {
|
||||||
|
return <Navigate to={jellyfinSectionPath(DEFAULT_JELLYFIN_SECTION)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceView workspace={workspace} locked />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'jellyfin-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'jellyfin-nav', appType: 'jellyfin-nav' }, size: 18 },
|
||||||
|
{ node: { type: 'panel', id: 'jellyfin-view', appType: 'jellyfin-view' }, size: 82 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './JellyfinScreen';
|
||||||
@@ -141,6 +141,7 @@ import {
|
|||||||
Images,
|
Images,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Contact,
|
Contact,
|
||||||
|
Clapperboard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||||
@@ -152,6 +153,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||||
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
|
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
|
||||||
|
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
|
||||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export * from './Music';
|
|||||||
export * from './Soulseek';
|
export * from './Soulseek';
|
||||||
export * from './Headscale';
|
export * from './Headscale';
|
||||||
export * from './Photos';
|
export * from './Photos';
|
||||||
|
export * from './Jellyfin';
|
||||||
export * from './Transmission';
|
export * from './Transmission';
|
||||||
export * from './Invoices';
|
export * from './Invoices';
|
||||||
export * from './Wallet';
|
export * from './Wallet';
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||||
|
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { getConfig, probe } from './upstream';
|
|||||||
// GET /_officer/items?parentId=… browse grid (allow-listed filters, poster fields always on)
|
// GET /_officer/items?parentId=… browse grid (allow-listed filters, poster fields always on)
|
||||||
// GET /_officer/items/:id full detail, incl. MediaSources
|
// GET /_officer/items/:id full detail, incl. MediaSources
|
||||||
// GET /_officer/items/:id/similar
|
// GET /_officer/items/:id/similar
|
||||||
// POST /_officer/items/:id/playback negotiate → { playMethod, url, isHls, playSessionId }
|
// POST /_officer/items/:id/playback negotiate → { playMethod, url, seekable, hlsUrl, playSessionId }
|
||||||
// POST|DEL /_officer/items/:id/played watched mark
|
// POST|DEL /_officer/items/:id/played watched mark
|
||||||
// POST|DEL /_officer/items/:id/favorite
|
// POST|DEL /_officer/items/:id/favorite
|
||||||
// GET /_officer/shows/:id/seasons
|
// GET /_officer/shows/:id/seasons
|
||||||
|
|||||||
@@ -5,9 +5,14 @@
|
|||||||
// browser too generously and playback dies silently on an unsupported codec; too conservatively and every
|
// browser too generously and playback dies silently on an unsupported codec; too conservatively and every
|
||||||
// file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container).
|
// file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container).
|
||||||
//
|
//
|
||||||
// It deliberately describes what a modern Chromium/WebKit `<video>` plus hls.js can actually play, which is
|
// It deliberately describes what a bare Chromium/WebKit `<video>` can play with no media library loaded, which
|
||||||
// narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any
|
// is narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any
|
||||||
// browser, so mkv is the common case that has to go out as HLS even when its streams are already h264/aac.
|
// browser, so mkv is the common case that has to be remuxed even when its streams are already h264/aac.
|
||||||
|
//
|
||||||
|
// Both transcoding profiles are declared, and the ORDER is load-bearing. HLS is first, so `TranscodingUrl` in
|
||||||
|
// the PlaybackInfo response stays an m3u8 the player carries but does not use (see routes.ts → `hlsUrl`). The
|
||||||
|
// progressive http profile below is what our player actually requests, and it exists so the server accepts
|
||||||
|
// that request; without it, `/Videos/{id}/stream.mp4?static=false` is refused as outside the profile.
|
||||||
//
|
//
|
||||||
// Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed
|
// Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed
|
||||||
// target (our own web player), and a profile assembled from feature detection in the browser is the classic
|
// target (our own web player), and a profile assembled from feature detection in the browser is the classic
|
||||||
@@ -43,12 +48,16 @@ export const DEVICE_PROFILE = {
|
|||||||
BreakOnNonKeyFrames: true,
|
BreakOnNonKeyFrames: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Progressive mp4 over plain http — what our player requests. `Streaming`, not `Static`: `Static` means
|
||||||
|
// "the whole file, transcoded up front", which for a live seek-by-restart player is the wrong contract
|
||||||
|
// and is refused for a stream opened at an offset.
|
||||||
Container: 'mp4',
|
Container: 'mp4',
|
||||||
Type: 'Video',
|
Type: 'Video',
|
||||||
AudioCodec: 'aac',
|
AudioCodec: 'aac',
|
||||||
VideoCodec: 'h264',
|
VideoCodec: 'h264',
|
||||||
Context: 'Static',
|
Context: 'Streaming',
|
||||||
Protocol: 'http',
|
Protocol: 'http',
|
||||||
|
MaxAudioChannels: '2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -190,8 +190,11 @@ async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
|
|||||||
|
|
||||||
type MediaSource = {
|
type MediaSource = {
|
||||||
Id?: string;
|
Id?: string;
|
||||||
|
Container?: string;
|
||||||
|
RunTimeTicks?: number;
|
||||||
SupportsDirectPlay?: boolean;
|
SupportsDirectPlay?: boolean;
|
||||||
SupportsDirectStream?: boolean;
|
SupportsDirectStream?: boolean;
|
||||||
|
DirectStreamUrl?: string;
|
||||||
TranscodingUrl?: string;
|
TranscodingUrl?: string;
|
||||||
};
|
};
|
||||||
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
|
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
|
||||||
@@ -213,13 +216,51 @@ function sanitizeUpstreamPath(raw: string): string {
|
|||||||
return `${path}${qs ? `?${qs}` : ''}`;
|
return `${path}${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
|
||||||
|
*
|
||||||
|
* This is the deliberate fallback instead of HLS, and the reason is a dependency this repo does not have:
|
||||||
|
* Chromium cannot play an HLS playlist natively, so the m3u8 path needs hls.js, and `bunfig.toml` freezes
|
||||||
|
* installs precisely so a new package is a considered act rather than a side effect of a feature. A
|
||||||
|
* progressive stream needs nothing.
|
||||||
|
*
|
||||||
|
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
|
||||||
|
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
|
||||||
|
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub.
|
||||||
|
*
|
||||||
|
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
|
||||||
|
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
|
||||||
|
* machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU.
|
||||||
|
*/
|
||||||
|
function progressivePath(id: string, source: MediaSource, playSessionId: string | null, startSeconds: number): string {
|
||||||
|
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
|
||||||
|
static: 'false',
|
||||||
|
container: 'mp4',
|
||||||
|
mediaSourceId: source.Id,
|
||||||
|
playSessionId: playSessionId ?? undefined,
|
||||||
|
videoCodec: 'h264',
|
||||||
|
audioCodec: 'aac',
|
||||||
|
audioBitrate: '192000',
|
||||||
|
maxAudioChannels: '2',
|
||||||
|
allowVideoStreamCopy: 'true',
|
||||||
|
allowAudioStreamCopy: 'true',
|
||||||
|
// No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which
|
||||||
|
// throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles
|
||||||
|
// are a deliberate later feature, chosen per-item, not a default that quietly costs that much.
|
||||||
|
startTimeTicks: startSeconds > 0 ? String(Math.round(startSeconds * TICKS_PER_SECOND)) : undefined,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
|
* Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
|
||||||
* one URL the player can use.
|
* one URL the player can use.
|
||||||
*
|
*
|
||||||
* The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
|
* The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
|
||||||
* the fly, still `/Videos/{id}/stream`) and transcode (HLS). They are reported explicitly rather than
|
* the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
|
||||||
* inferred, because "why is my CPU pinned" is a question the UI should be able to answer.
|
* because "why is my CPU pinned" is a question the UI should be able to answer.
|
||||||
|
*
|
||||||
|
* `hlsUrl` is returned alongside whenever Jellyfin offered one. Nothing consumes it yet — it is what the
|
||||||
|
* player switches to the day hls.js is added on purpose, and it costs nothing to carry until then.
|
||||||
*/
|
*/
|
||||||
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
||||||
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
|
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
|
||||||
@@ -260,16 +301,22 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
|
|||||||
if (!source) return bad('the server returned no playable source for this item', 502);
|
if (!source) return bad('the server returned no playable source for this item', 502);
|
||||||
|
|
||||||
const playSessionId = info.PlaySessionId ?? null;
|
const playSessionId = info.PlaySessionId ?? null;
|
||||||
const direct = source.SupportsDirectPlay || source.SupportsDirectStream;
|
const direct = !!(source.SupportsDirectPlay || source.SupportsDirectStream);
|
||||||
|
|
||||||
|
const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
|
||||||
|
static: 'true',
|
||||||
|
mediaSourceId: source.Id,
|
||||||
|
playSessionId: playSessionId ?? undefined,
|
||||||
|
})}`;
|
||||||
|
|
||||||
const path = direct
|
const path = direct
|
||||||
? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
|
? source.SupportsDirectPlay
|
||||||
static: 'true',
|
? staticPath
|
||||||
mediaSourceId: source.Id,
|
: (source.DirectStreamUrl && sanitizeUpstreamPath(source.DirectStreamUrl)) || staticPath
|
||||||
playSessionId: playSessionId ?? undefined,
|
: // A `TranscodingUrl` is Jellyfin agreeing to transcode. We ask for the same thing progressively
|
||||||
})}`
|
// instead of taking its HLS URL, but its absence still means "refused", so it stays the signal.
|
||||||
: source.TranscodingUrl
|
source.TranscodingUrl
|
||||||
? sanitizeUpstreamPath(source.TranscodingUrl)
|
? progressivePath(id, source, playSessionId, startSeconds)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!path) {
|
if (!path) {
|
||||||
@@ -280,11 +327,16 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
|
|||||||
playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
|
playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
|
||||||
// Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
|
// Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
|
||||||
url: `/_jf${path}`,
|
url: `/_jf${path}`,
|
||||||
isHls: !direct,
|
// A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
|
||||||
|
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
|
||||||
|
seekable: direct,
|
||||||
|
// Carried, not used. The day hls.js is a deliberate dependency, the player switches to this.
|
||||||
|
hlsUrl: source.TranscodingUrl ? `/_jf${sanitizeUpstreamPath(source.TranscodingUrl)}` : null,
|
||||||
playSessionId,
|
playSessionId,
|
||||||
mediaSourceId: source.Id ?? null,
|
mediaSourceId: source.Id ?? null,
|
||||||
mediaSource: source,
|
mediaSource: source,
|
||||||
startSeconds,
|
startSeconds,
|
||||||
|
runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { appRegistryMetas as musicMetas } from '../apps/Music';
|
|||||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||||
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
||||||
|
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
||||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||||
@@ -33,6 +34,7 @@ const apps = [
|
|||||||
...soulseekMetas,
|
...soulseekMetas,
|
||||||
...headscaleMetas,
|
...headscaleMetas,
|
||||||
...photosMetas,
|
...photosMetas,
|
||||||
|
...jellyfinMetas,
|
||||||
...transmissionMetas,
|
...transmissionMetas,
|
||||||
...invoicesMetas,
|
...invoicesMetas,
|
||||||
...walletMetas,
|
...walletMetas,
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import type { JellyfinServerRow } from './useJellyfinData';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { formatShortDate } from './shared';
|
||||||
|
import {
|
||||||
|
jellyfinErrorMessage,
|
||||||
|
useJellyfinHealth,
|
||||||
|
useJellyfinServerActions,
|
||||||
|
useJellyfinServers,
|
||||||
|
} from './useJellyfinData';
|
||||||
|
|
||||||
|
// Connecting Jellyfin servers to Officer, from the app.
|
||||||
|
//
|
||||||
|
// This screen is BOTH the setup wizard and the permanent settings page: JellyfinView renders it in place of
|
||||||
|
// whatever section the URL asks for while nothing is connected, and /jellyfin/settings renders it for good.
|
||||||
|
// One component, so a second server added later goes through exactly the code path that stored the first.
|
||||||
|
//
|
||||||
|
// There is no "paste an API key" alternative here, unlike the Immich screen. Jellyfin's API keys are
|
||||||
|
// SERVER-wide and identity-less — they authenticate as nobody, which breaks every per-user route this app
|
||||||
|
// depends on (resume points, next up, watched marks). Signing in is the only credential that has a user
|
||||||
|
// attached, so it is the only one offered. The password is used once and never stored.
|
||||||
|
|
||||||
|
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||||
|
|
||||||
|
/** Jellyfin's own default port. The sidecar dials from this machine, so localhost is the right default. */
|
||||||
|
const DEFAULT_URL = 'http://localhost:8096';
|
||||||
|
|
||||||
|
const URL_HINT =
|
||||||
|
"The server's base URL, without /web. Officer reaches it from the server, not from this browser — so " +
|
||||||
|
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
||||||
|
|
||||||
|
const SIGN_IN_HINT =
|
||||||
|
'Your Jellyfin login. It is used once, by the sidecar, to exchange for an access token — the password is ' +
|
||||||
|
'never stored and never reaches Postgres. The session shows up in Jellyfin under Devices as "Officer"; ' +
|
||||||
|
'signing it out there is what revokes this connection.';
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
type?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={type}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{hint && <span className={HINT}>{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
function useCredential() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const filled = !!username.trim() && !!password;
|
||||||
|
const reset = () => {
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { username, setUsername, password, setPassword, filled, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialState = ReturnType<typeof useCredential>;
|
||||||
|
|
||||||
|
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Username"
|
||||||
|
value={cred.username}
|
||||||
|
onChange={cred.setUsername}
|
||||||
|
placeholder="your Jellyfin user"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
value={cred.password}
|
||||||
|
onChange={cred.setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
hint={SIGN_IN_HINT}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored server. The active one carries the live health line, because health only ever describes the
|
||||||
|
* server actually in use — showing a status next to the others would be inventing one. "Test" on an inactive
|
||||||
|
* row probes it directly instead, which is the only way to check a server without switching to it first.
|
||||||
|
*/
|
||||||
|
const ServerRow = ({ server }: { server: JellyfinServerRow }) => {
|
||||||
|
const { data: health, refetch: recheck, isFetching: checking } = useJellyfinHealth();
|
||||||
|
const { edit, activate, remove, test } = useJellyfinServerActions();
|
||||||
|
|
||||||
|
const cred = useCredential();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [replacing, setReplacing] = useState(false);
|
||||||
|
// Removing a server throws away a token Officer can never show again, so the bin asks once.
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
const run = async (action: Promise<unknown>) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action;
|
||||||
|
cred.reset();
|
||||||
|
setReplacing(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(jellyfinErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const busy = checking || test.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${server.isActive ? 'border-primary/40' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{server.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
||||||
|
<span className="truncate font-medium">{server.label}</span>
|
||||||
|
{server.isActive && (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{!server.isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
disabled={activate.isPending}
|
||||||
|
onClick={() => void run(activate.mutateAsync(server.id))}
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void (server.isActive ? recheck() : run(test.mutateAsync(server.id)))}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirming) return setConfirming(true);
|
||||||
|
setConfirming(false);
|
||||||
|
void run(remove.mutateAsync(server.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="truncate text-muted-foreground">
|
||||||
|
{server.url}
|
||||||
|
{server.jellyfinUsername ? ` · ${server.jellyfinUsername}` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{server.isActive ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
{health?.ok ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{health?.ok
|
||||||
|
? `Jellyfin ${health.version ?? '?'}${health.serverName ? ` · ${health.serverName}` : ''}`
|
||||||
|
: (health?.error ?? 'not checked yet')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{server.version ? `Jellyfin ${server.version}` : 'version unknown'}
|
||||||
|
{server.lastSeenAt ? ` · last answered ${formatShortDate(server.lastSeenAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replacing ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||||
|
<CredentialFields cred={cred} autoFocus />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!cred.filled || edit.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void run(edit.mutateAsync({ id: server.id, username: cred.username.trim(), password: cred.password }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
cred.reset();
|
||||||
|
setReplacing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReplacing(true)}
|
||||||
|
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Sign in again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectionSection = () => {
|
||||||
|
const { data, isLoading } = useJellyfinServers();
|
||||||
|
const { add } = useJellyfinServerActions();
|
||||||
|
|
||||||
|
const cred = useCredential();
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const servers = data?.servers ?? [];
|
||||||
|
const hasServers = servers.length > 0;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!url.trim()) return setError('The Jellyfin URL is required');
|
||||||
|
if (!cred.filled) return setError('A username and password are required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await add.mutateAsync({
|
||||||
|
label: label.trim(),
|
||||||
|
url: url.trim(),
|
||||||
|
username: cred.username.trim(),
|
||||||
|
password: cred.password,
|
||||||
|
});
|
||||||
|
setLabel('');
|
||||||
|
setUrl(DEFAULT_URL);
|
||||||
|
cred.reset();
|
||||||
|
} catch (err) {
|
||||||
|
setError(jellyfinErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-500/15 text-violet-400">
|
||||||
|
<Plug className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">{hasServers ? 'Jellyfin servers' : 'Connect Jellyfin'}</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasServers
|
||||||
|
? 'Officer stores each server and its access token encrypted, for this account only. One is in use at a time.'
|
||||||
|
: 'Video needs a Jellyfin server and a login before it can show anything.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && servers.map((server) => <ServerRow key={server.id} server={server} />)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium">{hasServers ? 'Add another server' : 'Add a server'}</p>
|
||||||
|
<Field
|
||||||
|
label="Label"
|
||||||
|
value={label}
|
||||||
|
onChange={setLabel}
|
||||||
|
placeholder="Optional — defaults to the server's own name"
|
||||||
|
hint="What the switcher calls this server. Two servers cannot share a label; two can share a URL."
|
||||||
|
autoFocus={hasServers}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Jellyfin URL"
|
||||||
|
value={url}
|
||||||
|
onChange={setUrl}
|
||||||
|
placeholder={DEFAULT_URL}
|
||||||
|
hint={URL_HINT}
|
||||||
|
autoFocus={!hasServers}
|
||||||
|
/>
|
||||||
|
<CredentialFields cred={cred} />
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={add.isPending}>
|
||||||
|
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{add.isPending ? 'Verifying…' : hasServers ? 'Add server' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
{add.isPending && <span className={HINT}>Checking the server and signing in…</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { MediaShelf } from './MediaCard';
|
||||||
|
import { useJellyfinHome } from './useJellyfinData';
|
||||||
|
|
||||||
|
// The landing screen: what you were watching, what comes next, and the newest thing in each library.
|
||||||
|
//
|
||||||
|
// One request. The sidecar fans out to Jellyfin's four different endpoints and swallows per-library failures,
|
||||||
|
// so a library mid-scan costs its own shelf rather than the whole page — which is why there is no per-shelf
|
||||||
|
// loading state here to write.
|
||||||
|
|
||||||
|
export const HomeSection = () => {
|
||||||
|
const { data, isLoading, error } = useJellyfinHome();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
||||||
|
The server did not answer.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = data?.resume ?? [];
|
||||||
|
const nextUp = data?.nextUp ?? [];
|
||||||
|
const latest = data?.latest ?? [];
|
||||||
|
const empty = !resume.length && !nextUp.length && !latest.some((shelf) => shelf.items.length);
|
||||||
|
|
||||||
|
if (empty) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium">Nothing here yet</p>
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
The server is connected but its video libraries are empty — or still scanning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-6 p-4">
|
||||||
|
<MediaShelf title="Continue watching" items={resume} wide />
|
||||||
|
<MediaShelf title="Next up" items={nextUp} wide />
|
||||||
|
{latest.map((shelf) => (
|
||||||
|
<MediaShelf key={shelf.viewId} title={`Latest in ${shelf.viewName}`} items={shelf.items} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import type { JellyItem } from './shared';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||||
|
import { Check, Heart, Loader2, Play, X } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
backdropUrl,
|
||||||
|
closeHref,
|
||||||
|
episodeLabel,
|
||||||
|
formatBytes,
|
||||||
|
formatRuntime,
|
||||||
|
ITEM_PARAM,
|
||||||
|
itemHref,
|
||||||
|
posterUrl,
|
||||||
|
progressPercent,
|
||||||
|
ticksToSeconds,
|
||||||
|
} from './shared';
|
||||||
|
import { useEpisodes, useItemFlags, useJellyfinItem, useSeasons } from './useJellyfinData';
|
||||||
|
|
||||||
|
// The detail sheet — an overlay ON the grid, opened by `?item=<id>`.
|
||||||
|
//
|
||||||
|
// It is not a route because the grid behind it should stay mounted and scrolled: closing is dropping one
|
||||||
|
// query parameter, and the back button does it for free. The same component covers a film, a series, a season
|
||||||
|
// and an episode, because Jellyfin's item shape does — what changes is which extra list is worth showing
|
||||||
|
// underneath, and that is decided by `Type`.
|
||||||
|
|
||||||
|
const META = 'text-xs text-muted-foreground';
|
||||||
|
|
||||||
|
const Chip = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The technical line — what is actually in the file, which is what predicts whether it will transcode. */
|
||||||
|
const StreamSummary = ({ item }: { item: JellyItem }) => {
|
||||||
|
const source = item.MediaSources?.[0];
|
||||||
|
if (!source) return null;
|
||||||
|
const video = source.MediaStreams?.find((s) => s.Type === 'Video');
|
||||||
|
const audio = source.MediaStreams?.find((s) => s.Type === 'Audio');
|
||||||
|
const parts = [
|
||||||
|
source.Container?.toUpperCase(),
|
||||||
|
video?.Height ? `${video.Height}p` : null,
|
||||||
|
video?.Codec?.toUpperCase(),
|
||||||
|
audio?.Codec?.toUpperCase(),
|
||||||
|
audio?.Channels ? `${audio.Channels}ch` : null,
|
||||||
|
formatBytes(source.Size),
|
||||||
|
].filter(Boolean);
|
||||||
|
return <p className={META}>{parts.join(' · ')}</p>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SeriesEpisodes = ({ series }: { series: JellyItem }) => {
|
||||||
|
const { data: seasonData } = useSeasons(series.Id);
|
||||||
|
const seasons = seasonData?.Items ?? [];
|
||||||
|
const [seasonId, setSeasonId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Default to the first season only once the list arrives; picking one is then the owner's choice and is
|
||||||
|
// not overwritten by a refetch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seasonId && seasons.length) setSeasonId(seasons[0]?.Id ?? null);
|
||||||
|
}, [seasonId, seasons]);
|
||||||
|
|
||||||
|
const { data: episodeData, isLoading } = useEpisodes(series.Id, seasonId);
|
||||||
|
const episodes = episodeData?.Items ?? [];
|
||||||
|
|
||||||
|
if (!seasons.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<button
|
||||||
|
key={season.Id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSeasonId(season.Id)}
|
||||||
|
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
|
||||||
|
seasonId === season.Id ? 'bg-primary/10 font-medium text-primary' : 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{season.Name ?? `Season ${season.IndexNumber ?? '?'}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{episodes.map((episode) => (
|
||||||
|
<EpisodeRow key={episode.Id} episode={episode} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EpisodeRow = ({ episode }: { episode: JellyItem }) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const progress = progressPercent(episode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="relative">
|
||||||
|
<Link to={itemHref(pathname, params, episode.Id, true)} className="flex gap-3 p-3 hover:bg-muted/50">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium">{episodeLabel(episode)}</p>
|
||||||
|
{episode.Overview && <p className="line-clamp-2 text-[11px] text-muted-foreground">{episode.Overview}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">{formatRuntime(episode.RunTimeTicks)}</span>
|
||||||
|
{episode.UserData?.Played && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{progress > 0 && progress < 100 && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" style={{ width: `${progress}%` }} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemDetail = ({ id }: { id: string }) => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const { data: item, isLoading } = useJellyfinItem(id);
|
||||||
|
const { setPlayed, setFavorite } = useItemFlags();
|
||||||
|
|
||||||
|
const close = closeHref(pathname, params, ITEM_PARAM);
|
||||||
|
|
||||||
|
if (isLoading || !item) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/95">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdrop = backdropUrl(item, token);
|
||||||
|
const poster = posterUrl(item, token, 300);
|
||||||
|
const resumeSeconds = ticksToSeconds(item.UserData?.PlaybackPositionTicks);
|
||||||
|
const played = item.UserData?.Played === true;
|
||||||
|
const favorite = item.UserData?.IsFavorite === true;
|
||||||
|
|
||||||
|
// A series is not playable itself; its Next Up episode is. Sending the series id to the player would ask
|
||||||
|
// Jellyfin to negotiate playback for a folder, which fails in a way that reads as a broken video.
|
||||||
|
const playableId = item.Type === 'Series' ? null : item.Id;
|
||||||
|
|
||||||
|
const meta = [
|
||||||
|
item.ProductionYear ? String(item.ProductionYear) : null,
|
||||||
|
formatRuntime(item.RunTimeTicks),
|
||||||
|
item.OfficialRating,
|
||||||
|
item.CommunityRating ? `★ ${item.CommunityRating.toFixed(1)}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-20 overflow-y-auto bg-background">
|
||||||
|
<div className="relative">
|
||||||
|
{backdrop && (
|
||||||
|
<div className="relative h-48 w-full overflow-hidden sm:h-64">
|
||||||
|
<img src={backdrop} alt="" className="h-full w-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={close}
|
||||||
|
aria-label="Close"
|
||||||
|
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex flex-col gap-4 p-4 ${backdrop ? '-mt-16' : ''}`}>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{poster && (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt=""
|
||||||
|
className="h-40 w-[107px] shrink-0 rounded-lg object-cover shadow-lg ring-1 ring-black/10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<h2 className="text-lg font-semibold leading-tight">{item.Name}</h2>
|
||||||
|
{item.Type === 'Episode' && item.SeriesName && (
|
||||||
|
<p className={META}>
|
||||||
|
{item.SeriesName} · {episodeLabel(item)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.Taglines?.[0] && <p className="text-xs italic text-muted-foreground">{item.Taglines[0]}</p>}
|
||||||
|
<p className={META}>{meta.join(' · ')}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.Genres?.slice(0, 5).map((g) => (
|
||||||
|
<Chip key={g}>{g}</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{playableId && (
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link to={itemHref(pathname, params, playableId, true)}>
|
||||||
|
<Play className="mr-1.5 h-3.5 w-3.5" fill="currentColor" />
|
||||||
|
{resumeSeconds > 0 ? 'Resume' : 'Play'}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={setPlayed.isPending}
|
||||||
|
onClick={() => void setPlayed.mutateAsync({ id: item.Id, played: !played }).catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Check className={`mr-1.5 h-3.5 w-3.5 ${played ? 'text-primary' : ''}`} />
|
||||||
|
{played ? 'Watched' : 'Mark watched'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={setFavorite.isPending}
|
||||||
|
onClick={() => void setFavorite.mutateAsync({ id: item.Id, favorite: !favorite }).catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Heart className={`mr-1.5 h-3.5 w-3.5 ${favorite ? 'fill-current text-red-500' : ''}`} />
|
||||||
|
Favourite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.Overview && <p className="max-w-3xl text-xs leading-relaxed text-muted-foreground">{item.Overview}</p>}
|
||||||
|
|
||||||
|
<StreamSummary item={item} />
|
||||||
|
|
||||||
|
{item.Type === 'Series' && <SeriesEpisodes series={item} />}
|
||||||
|
|
||||||
|
{!!item.People?.length && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">Cast</h3>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{item.People.filter((person) => person.Type === 'Actor')
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((person) => (
|
||||||
|
<p key={`${person.Id}-${person.Role}`} className="text-[11px]">
|
||||||
|
<span className="font-medium">{person.Name}</span>
|
||||||
|
{person.Role && <span className="text-muted-foreground"> as {person.Role}</span>}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { JellyItem } from './shared';
|
||||||
|
import { Link, NavLink, useSearchParams } from 'react-router';
|
||||||
|
import { Clapperboard, Film, FolderOpen, Home, Images, ListVideo, Plug, Search, Tv } from 'lucide-react';
|
||||||
|
import { jellyfinSectionPath, VIEW_PARAM } from './shared';
|
||||||
|
import { ServerSwitcher } from './ServerSwitcher';
|
||||||
|
import { useJellyfinViews } from './useJellyfinData';
|
||||||
|
|
||||||
|
// Left panel of the /jellyfin workspace: Home and Search, the libraries the server actually has, then the
|
||||||
|
// connection screen.
|
||||||
|
//
|
||||||
|
// The libraries are not a fixed vocabulary, so they cannot be sections in the path — they are
|
||||||
|
// /jellyfin/library?view=<id>. That keeps the section list closed (four entries, checked by a type guard)
|
||||||
|
// while the thing being browsed stays addressable, which is the same split the photos app uses for albums.
|
||||||
|
//
|
||||||
|
// Their active state is computed rather than taken from NavLink, because every library link shares the path
|
||||||
|
// /jellyfin/library and react-router's isActive does not look at the query string.
|
||||||
|
|
||||||
|
const ICON_FOR_COLLECTION: Record<string, typeof Film> = {
|
||||||
|
movies: Film,
|
||||||
|
tvshows: Tv,
|
||||||
|
homevideos: Images,
|
||||||
|
musicvideos: Clapperboard,
|
||||||
|
boxsets: FolderOpen,
|
||||||
|
playlists: ListVideo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||||
|
const ACTIVE = 'bg-primary/10 font-medium text-primary';
|
||||||
|
const IDLE = 'text-muted-foreground hover:bg-muted hover:text-foreground';
|
||||||
|
|
||||||
|
const Marker = () => <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />;
|
||||||
|
|
||||||
|
export const JellyfinNav = () => {
|
||||||
|
const { data } = useJellyfinViews();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const activeView = params.get(VIEW_PARAM);
|
||||||
|
|
||||||
|
const views = data?.views ?? [];
|
||||||
|
|
||||||
|
const sectionLink = (id: 'home' | 'search' | 'settings', label: string, Icon: typeof Film) => (
|
||||||
|
<NavLink key={id} to={jellyfinSectionPath(id)} className={({ isActive }) => `${ROW} ${isActive ? ACTIVE : IDLE}`}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && <Marker />}
|
||||||
|
<Icon
|
||||||
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
const libraryLink = (view: JellyItem) => {
|
||||||
|
const Icon = ICON_FOR_COLLECTION[view.CollectionType ?? ''] ?? FolderOpen;
|
||||||
|
const isActive = activeView === view.Id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={view.Id}
|
||||||
|
to={`${jellyfinSectionPath('library')}?${VIEW_PARAM}=${encodeURIComponent(view.Id)}`}
|
||||||
|
className={`${ROW} ${isActive ? ACTIVE : IDLE}`}
|
||||||
|
>
|
||||||
|
{isActive && <Marker />}
|
||||||
|
<Icon
|
||||||
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{view.Name ?? 'Library'}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-500/15 text-violet-400 ring-1 ring-black/5">
|
||||||
|
<Clapperboard className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold leading-tight">Video</div>
|
||||||
|
<ServerSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2">
|
||||||
|
{sectionLink('home', 'Home', Home)}
|
||||||
|
{sectionLink('search', 'Search', Search)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{views.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mx-4 my-2 border-t" />
|
||||||
|
<p className="px-4 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Libraries</p>
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2">{views.map(libraryLink)}</nav>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="mx-4 my-2 border-t" />
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">{sectionLink('settings', 'Connection', Plug)}</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { ConnectionSection } from './ConnectionSection';
|
||||||
|
import { HomeSection } from './HomeSection';
|
||||||
|
import { ItemDetail } from './ItemDetail';
|
||||||
|
import { LibrarySection } from './LibrarySection';
|
||||||
|
import { SearchSection } from './SearchSection';
|
||||||
|
import { VideoPlayer } from './VideoPlayer';
|
||||||
|
import { ITEM_PARAM, jellyfinSectionPath, PLAY_PARAM } from './shared';
|
||||||
|
import { useJellyfinHealth } from './useJellyfinData';
|
||||||
|
import { useJellyfinSection } from './useJellyfinSection';
|
||||||
|
|
||||||
|
// Right panel of the /jellyfin workspace — renders the section named by the URL, with the detail sheet and
|
||||||
|
// the player as overlays on top of it.
|
||||||
|
//
|
||||||
|
// Health gates all of them, and its two failure modes are answered differently: nothing configured yet is the
|
||||||
|
// setup form, whatever section was asked for, because there is nothing else useful to show; a stored server
|
||||||
|
// that is failing keeps its own message and a way back to the connection screen, since replacing a working
|
||||||
|
// sign-in by accident is worse than a wall of text.
|
||||||
|
//
|
||||||
|
// The player wins over the detail sheet when both parameters are set, and `itemHref` never sets both — but
|
||||||
|
// the precedence is written down rather than left to whichever renders last.
|
||||||
|
|
||||||
|
export const JellyfinView = () => {
|
||||||
|
const section = useJellyfinSection();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const { data: health, isLoading } = useJellyfinHealth();
|
||||||
|
|
||||||
|
const playId = params.get(PLAY_PARAM);
|
||||||
|
const itemId = params.get(ITEM_PARAM);
|
||||||
|
|
||||||
|
if (section === 'settings') return <ConnectionSection />;
|
||||||
|
|
||||||
|
if (!isLoading && health && !health.ok) {
|
||||||
|
if (health.configured === false) return <ConnectionSection />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium">Jellyfin is not answering</p>
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
{health.error ?? 'The jellyfin sidecar could not reach the configured server.'}
|
||||||
|
</p>
|
||||||
|
<Link to={jellyfinSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||||
|
Check the connection
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = section === 'library' ? <LibrarySection /> : section === 'search' ? <SearchSection /> : <HomeSection />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
{body}
|
||||||
|
{playId ? <VideoPlayer id={playId} /> : itemId ? <ItemDetail id={itemId} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Clapperboard } from 'lucide-react';
|
||||||
|
import { JELLYFIN_SECTIONS } from './shared';
|
||||||
|
import { useJellyfinSection } from './useJellyfinSection';
|
||||||
|
|
||||||
|
// Panel header for the right (jellyfin-view) panel.
|
||||||
|
|
||||||
|
export const JellyfinViewHeader = () => {
|
||||||
|
const section = useJellyfinSection();
|
||||||
|
const label = JELLYFIN_SECTIONS.find((s) => s.id === section)?.label ?? 'Video';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Clapperboard className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate text-xs font-medium">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { MediaGrid } from './MediaCard';
|
||||||
|
import { VIEW_PARAM } from './shared';
|
||||||
|
import { useJellyfinItems, useJellyfinViews } from './useJellyfinData';
|
||||||
|
|
||||||
|
// One library, as a grid.
|
||||||
|
//
|
||||||
|
// Which library is `?view=<id>`; the sort is `?sort=` and `?order=`. All three are in the URL rather than in
|
||||||
|
// component state so a particular view of a particular library is a link you can keep — and so the nav can
|
||||||
|
// tell which library is open without either panel talking to the other.
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ id: 'SortName', label: 'Name' },
|
||||||
|
{ id: 'DateCreated', label: 'Recently added' },
|
||||||
|
{ id: 'PremiereDate', label: 'Release date' },
|
||||||
|
{ id: 'CommunityRating', label: 'Rating' },
|
||||||
|
{ id: 'Random', label: 'Random' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SORT_PARAM = 'sort';
|
||||||
|
const ORDER_PARAM = 'order';
|
||||||
|
|
||||||
|
// A show is a Series, not its episodes: a tvshows library browsed recursively for Episode would be thousands
|
||||||
|
// of tiles of the same six posters. BoxSets are collapsed for the same reason.
|
||||||
|
const TYPES_FOR_COLLECTION: Record<string, string> = {
|
||||||
|
movies: 'Movie',
|
||||||
|
tvshows: 'Series',
|
||||||
|
homevideos: 'Video,Photo',
|
||||||
|
musicvideos: 'MusicVideo',
|
||||||
|
boxsets: 'BoxSet',
|
||||||
|
playlists: 'Playlist',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHIP = 'rounded-md px-2 py-1 text-[11px] transition-colors';
|
||||||
|
|
||||||
|
export const LibrarySection = () => {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const viewId = params.get(VIEW_PARAM);
|
||||||
|
const sortBy = params.get(SORT_PARAM) ?? 'SortName';
|
||||||
|
const sortOrder = params.get(ORDER_PARAM) === 'Descending' ? 'Descending' : 'Ascending';
|
||||||
|
|
||||||
|
const { data: viewData } = useJellyfinViews();
|
||||||
|
const view = viewData?.views.find((v) => v.Id === viewId) ?? null;
|
||||||
|
|
||||||
|
const { data, isLoading } = useJellyfinItems(
|
||||||
|
{
|
||||||
|
parentId: viewId ?? undefined,
|
||||||
|
includeItemTypes: TYPES_FOR_COLLECTION[view?.CollectionType ?? ''] ?? undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
limit: 500,
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
},
|
||||||
|
!!viewId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!viewId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
||||||
|
Pick a library on the left.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = data?.Items ?? [];
|
||||||
|
const wide = view?.CollectionType === 'homevideos';
|
||||||
|
|
||||||
|
const setSort = (id: string) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
// Clicking the sort you are already on flips the direction, which is the behaviour every table has and
|
||||||
|
// saves a second control that would only ever be used with this one.
|
||||||
|
if (id === sortBy) next.set(ORDER_PARAM, sortOrder === 'Ascending' ? 'Descending' : 'Ascending');
|
||||||
|
else next.delete(ORDER_PARAM);
|
||||||
|
next.set(SORT_PARAM, id);
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||||
|
<h2 className="truncate text-sm font-semibold">{view?.Name ?? 'Library'}</h2>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{data?.TotalRecordCount != null ? `${data.TotalRecordCount.toLocaleString()} items` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-0.5 rounded-lg bg-muted p-0.5">
|
||||||
|
{SORTS.map((sort) => (
|
||||||
|
<button
|
||||||
|
key={sort.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSort(sort.id)}
|
||||||
|
className={`${CHIP} ${sortBy === sort.id ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
{sortBy === sort.id && sort.id !== 'Random' ? (sortOrder === 'Ascending' ? ' ↑' : ' ↓') : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : items.length ? (
|
||||||
|
<MediaGrid items={items} wide={wide} />
|
||||||
|
) : (
|
||||||
|
<p className="pt-10 text-center text-xs text-muted-foreground">This library is empty.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { JellyItem } from './shared';
|
||||||
|
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||||
|
import { Check, Play } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { episodeLabel, itemHref, posterUrl, progressPercent, subtitleFor, thumbUrl } from './shared';
|
||||||
|
|
||||||
|
// One tile in a shelf or a grid.
|
||||||
|
//
|
||||||
|
// It is a real <Link> to `?item=<id>` — cmd-click opens the detail in a new tab, the URL says what is open,
|
||||||
|
// and the back button closes it. The play button is a SIBLING anchor rather than nested inside, because an
|
||||||
|
// anchor inside an anchor is not valid HTML and browsers resolve it by dropping one of them.
|
||||||
|
|
||||||
|
type MediaCardProps = { item: JellyItem; wide?: boolean };
|
||||||
|
|
||||||
|
export const MediaCard = ({ item, wide = false }: MediaCardProps) => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
|
const src = wide ? thumbUrl(item, token) : posterUrl(item, token);
|
||||||
|
const progress = progressPercent(item);
|
||||||
|
const watched = item.UserData?.Played === true;
|
||||||
|
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||||
|
const subtitle = subtitleFor(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/card flex flex-col gap-1.5">
|
||||||
|
<div className="relative">
|
||||||
|
<Link
|
||||||
|
to={itemHref(pathname, params, item.Id)}
|
||||||
|
className={`relative block overflow-hidden rounded-lg bg-muted ring-1 ring-black/5 transition-transform hover:scale-[1.02] ${
|
||||||
|
wide ? 'aspect-video' : 'aspect-[2/3]'
|
||||||
|
}`}
|
||||||
|
title={item.Name ?? undefined}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-2 text-center text-[11px] text-muted-foreground">
|
||||||
|
{item.Name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watched && (
|
||||||
|
<span className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!watched && unplayed > 0 && (
|
||||||
|
<span className="absolute right-1.5 top-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
|
||||||
|
{unplayed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{progress > 0 && progress < 100 && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||||
|
<span className="block h-full bg-primary" style={{ width: `${progress}%` }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={itemHref(pathname, params, item.Id, true)}
|
||||||
|
aria-label={`Play ${item.Name ?? 'item'}`}
|
||||||
|
className="absolute left-1/2 top-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover/card:opacity-100"
|
||||||
|
>
|
||||||
|
<Play className="h-5 w-5 translate-x-px" fill="currentColor" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-medium leading-tight">{episodeLabel(item)}</p>
|
||||||
|
{subtitle && <p className="truncate text-[11px] text-muted-foreground">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A horizontal shelf. Overflow scrolls rather than wrapping — a shelf that wraps is a grid with a title. */
|
||||||
|
export const MediaShelf = ({ title, items, wide }: { title: string; items: JellyItem[]; wide?: boolean }) => {
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="px-1 text-sm font-semibold">{title}</h3>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.Id} className={wide ? 'w-56 shrink-0' : 'w-32 shrink-0'}>
|
||||||
|
<MediaCard item={item} wide={wide} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A wrapping grid. Column count comes from a min tile width, so it works in a panel of any size. */
|
||||||
|
export const MediaGrid = ({ items, wide }: { items: JellyItem[]; wide?: boolean }) => (
|
||||||
|
<div
|
||||||
|
className="grid gap-x-3 gap-y-4"
|
||||||
|
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${wide ? 200 : 130}px, 1fr))` }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<MediaCard key={item.Id} item={item} wide={wide} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { Loader2, Search } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { MediaGrid } from './MediaCard';
|
||||||
|
import { QUERY_PARAM } from './shared';
|
||||||
|
import { useJellyfinSearch } from './useJellyfinData';
|
||||||
|
|
||||||
|
// Search across every video library.
|
||||||
|
//
|
||||||
|
// The term is `?q=` — in the URL, not in component state, so a result set is a link and the browser's own
|
||||||
|
// back button steps through what you looked for. `replace` on every keystroke keeps that history one entry
|
||||||
|
// per search rather than one per letter.
|
||||||
|
|
||||||
|
export const SearchSection = () => {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const term = params.get(QUERY_PARAM) ?? '';
|
||||||
|
const { data, isFetching } = useJellyfinSearch(term);
|
||||||
|
|
||||||
|
const setTerm = (value: string) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
if (value) next.set(QUERY_PARAM, value);
|
||||||
|
else next.delete(QUERY_PARAM);
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = data?.Items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={term}
|
||||||
|
onChange={(ev) => setTerm(ev.target.value)}
|
||||||
|
placeholder="Search films, shows and episodes"
|
||||||
|
className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||||
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{isFetching && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{term.trim().length < 2 ? (
|
||||||
|
<p className="pt-10 text-center text-xs text-muted-foreground">Type at least two characters.</p>
|
||||||
|
) : items.length ? (
|
||||||
|
<MediaGrid items={items} />
|
||||||
|
) : (
|
||||||
|
!isFetching && <p className="pt-10 text-center text-xs text-muted-foreground">Nothing matched “{term}”.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { jellyfinSectionPath } from './shared';
|
||||||
|
import { useJellyfinHealth, useJellyfinServerActions, useJellyfinServers } from './useJellyfinData';
|
||||||
|
|
||||||
|
// Which Jellyfin you are looking at, and how to change it.
|
||||||
|
//
|
||||||
|
// This machine really does run several instances, so the switcher is not hypothetical — but only the video
|
||||||
|
// one is expected here; the album and DJ-set instances are the music player's business, and the sidecar
|
||||||
|
// already drops music libraries from every listing.
|
||||||
|
//
|
||||||
|
// Switching invalidates the whole ['jellyfin'] key, because it changes the answer to every query in the
|
||||||
|
// workspace without changing any of their inputs. Anything less and the previous server's posters stay up.
|
||||||
|
|
||||||
|
export const ServerSwitcher = () => {
|
||||||
|
const { data } = useJellyfinServers();
|
||||||
|
const { data: health } = useJellyfinHealth();
|
||||||
|
const { activate } = useJellyfinServerActions();
|
||||||
|
|
||||||
|
const servers = data?.servers ?? [];
|
||||||
|
const active = servers.find((server) => server.isActive) ?? null;
|
||||||
|
|
||||||
|
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||||
|
const status = active?.label ?? (health?.ok ? (health.serverName ?? 'connected') : 'not connected');
|
||||||
|
|
||||||
|
if (servers.length < 2) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={jellyfinSectionPath('settings')}
|
||||||
|
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<span className="truncate">{status}</span>
|
||||||
|
{activate.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Jellyfin servers</DropdownMenuLabel>
|
||||||
|
{servers.map((server) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={server.id}
|
||||||
|
disabled={server.isActive || activate.isPending}
|
||||||
|
onSelect={() => void activate.mutateAsync(server.id).catch(() => undefined)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate">{server.label}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">{server.url}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={jellyfinSectionPath('settings')} className="gap-2">
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
Manage servers
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type { PlaybackPlan } from './shared';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||||
|
import { Loader2, Maximize2, Pause, Play, X } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import {
|
||||||
|
closeHref,
|
||||||
|
episodeLabel,
|
||||||
|
formatClock,
|
||||||
|
PLAY_PARAM,
|
||||||
|
streamUrl,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ticksToSeconds,
|
||||||
|
} from './shared';
|
||||||
|
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
|
||||||
|
|
||||||
|
// The player — a full-panel overlay opened by `?play=<id>`.
|
||||||
|
//
|
||||||
|
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: a transcoded stream has no length and no byte
|
||||||
|
// ranges, so the browser cannot seek it. `plan.seekable` says which world we are in, and the two are handled
|
||||||
|
// differently rather than pretended to be the same:
|
||||||
|
//
|
||||||
|
// direct play / direct stream → a real file. Native controls, native seeking, `currentTime` is the position.
|
||||||
|
// transcode → a live progressive mp4 starting at an offset. Seeking means asking the
|
||||||
|
// server for a NEW stream at a new offset, so the position is
|
||||||
|
// `offset + currentTime` and the scrubber is ours.
|
||||||
|
//
|
||||||
|
// Every progress report uses that same computed position, which is why resume points are correct in both
|
||||||
|
// modes instead of being wrong by the offset in one of them.
|
||||||
|
|
||||||
|
const PROGRESS_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
export const VideoPlayer = ({ id }: { id: string }) => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const { data: item } = useJellyfinItem(id);
|
||||||
|
const negotiate = usePlaybackPlan();
|
||||||
|
const report = usePlaystateReporter();
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [plan, setPlan] = useState<PlaybackPlan | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
// Refs shadow the state the unmount cleanup needs. A cleanup closes over the values from the render that
|
||||||
|
// registered it, and the last thing this component does — report where playback stopped — must use the
|
||||||
|
// position at the moment of unmount, not the one from whenever the effect last ran.
|
||||||
|
const planRef = useRef<PlaybackPlan | null>(null);
|
||||||
|
const positionRef = useRef(0);
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
|
const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
|
||||||
|
|
||||||
|
const sendReport = useCallback(
|
||||||
|
(action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
|
||||||
|
const current = planRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
report(action, {
|
||||||
|
ItemId: id,
|
||||||
|
PlaySessionId: current.playSessionId,
|
||||||
|
MediaSourceId: current.mediaSourceId,
|
||||||
|
PositionTicks: Math.max(0, Math.round(positionRef.current * TICKS_PER_SECOND)),
|
||||||
|
IsPaused: isPaused,
|
||||||
|
CanSeek: current.seekable,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[id, report],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Open (or re-open) the stream at an offset. A seek in transcode mode is exactly this call. */
|
||||||
|
const open = useCallback(
|
||||||
|
async (startSeconds: number) => {
|
||||||
|
// Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
|
||||||
|
// Jellyfin shows two sessions playing the same thing.
|
||||||
|
if (planRef.current) sendReport('stopped');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const next = await negotiate.mutateAsync({ id, startSeconds });
|
||||||
|
planRef.current = next;
|
||||||
|
positionRef.current = next.startSeconds;
|
||||||
|
setPlan(next);
|
||||||
|
setPosition(next.startSeconds);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as { message?: string } | null)?.message ?? 'Playback could not be started');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, negotiate, sendReport],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start once, at the stored resume point. Guarded by a ref rather than by the dependency list because
|
||||||
|
// React runs effects twice in development and a second negotiation would start a second transcode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedRef.current || !item) return;
|
||||||
|
startedRef.current = true;
|
||||||
|
void open(ticksToSeconds(item.UserData?.PlaybackPositionTicks));
|
||||||
|
}, [item, open]);
|
||||||
|
|
||||||
|
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
sendReport('stopped');
|
||||||
|
planRef.current = null;
|
||||||
|
},
|
||||||
|
[sendReport],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plan) return;
|
||||||
|
const timer = setInterval(() => sendReport('progress', videoRef.current?.paused ?? false), PROGRESS_INTERVAL_MS);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [plan, sendReport]);
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !plan) return;
|
||||||
|
// In transcode mode the element's clock starts at zero for a stream that begins mid-film.
|
||||||
|
const next = plan.seekable ? video.currentTime : plan.startSeconds + video.currentTime;
|
||||||
|
positionRef.current = next;
|
||||||
|
setPosition(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const seek = (seconds: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
|
||||||
|
if (plan?.seekable && video) {
|
||||||
|
video.currentTime = clamped;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void open(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) void video.play().catch(() => undefined);
|
||||||
|
else video.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = closeHref(pathname, params, PLAY_PARAM);
|
||||||
|
const title = item ? episodeLabel(item) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 flex flex-col bg-black">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-white">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">{title}</span>
|
||||||
|
{plan && plan.playMethod !== 'DirectPlay' && (
|
||||||
|
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
|
||||||
|
{plan.playMethod === 'Transcode' ? 'transcoding' : 'remuxing'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={close}
|
||||||
|
aria-label="Close player"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex-1 bg-black">
|
||||||
|
{error ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center text-white">
|
||||||
|
<p className="text-sm font-medium">This will not play</p>
|
||||||
|
<p className="max-w-md text-xs text-white/60">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : plan ? (
|
||||||
|
<video
|
||||||
|
key={plan.url}
|
||||||
|
ref={videoRef}
|
||||||
|
src={streamUrl(plan.url, token)}
|
||||||
|
className="h-full w-full"
|
||||||
|
autoPlay
|
||||||
|
// A direct file is seekable, so the browser's own controls are correct and better than ours.
|
||||||
|
// A live transcode is not, and its controls would show a scrubber that does nothing.
|
||||||
|
controls={plan.seekable}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onPlay={() => {
|
||||||
|
setPaused(false);
|
||||||
|
sendReport('playing');
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setPaused(true);
|
||||||
|
sendReport('progress', true);
|
||||||
|
}}
|
||||||
|
onEnded={() => sendReport('stopped')}
|
||||||
|
onError={() => setError('The stream stopped unexpectedly.')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-white/60">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan && !plan.seekable && !error && (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 text-white">
|
||||||
|
<button type="button" onClick={togglePlay} aria-label={paused ? 'Play' : 'Pause'} className="shrink-0">
|
||||||
|
{paused ? <Play className="h-4 w-4" fill="currentColor" /> : <Pause className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums">{formatClock(position)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(1, Math.floor(duration))}
|
||||||
|
value={Math.floor(position)}
|
||||||
|
// `onChange` fires on every pixel of a drag, and each one would start a transcode. Committing on
|
||||||
|
// release is what makes a scrub cost one ffmpeg instead of forty.
|
||||||
|
onChange={(ev) => setPosition(Number(ev.target.value))}
|
||||||
|
onMouseUp={(ev) => seek(Number((ev.target as HTMLInputElement).value))}
|
||||||
|
onTouchEnd={(ev) => seek(Number((ev.target as HTMLInputElement).value))}
|
||||||
|
onKeyUp={(ev) => seek(Number((ev.target as HTMLInputElement).value))}
|
||||||
|
className="h-1 flex-1 accent-primary"
|
||||||
|
aria-label="Seek"
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums">{formatClock(duration)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void videoRef.current?.requestFullscreen().catch(() => undefined)}
|
||||||
|
aria-label="Fullscreen"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { Clapperboard, PanelLeft } from 'lucide-react';
|
||||||
|
import { JellyfinNav } from './JellyfinNav';
|
||||||
|
import { JellyfinView } from './JellyfinView';
|
||||||
|
import { JellyfinViewHeader } from './JellyfinViewHeader';
|
||||||
|
|
||||||
|
export { JellyfinNav, JellyfinView };
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'jellyfin-nav', name: 'Video', icon: PanelLeft, component: JellyfinNav, availableOnPanel: false },
|
||||||
|
{
|
||||||
|
key: 'jellyfin-view',
|
||||||
|
name: 'Video',
|
||||||
|
icon: Clapperboard,
|
||||||
|
component: JellyfinView,
|
||||||
|
header: JellyfinViewHeader,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Shared types, constants and pure helpers for the /jellyfin workspace panels.
|
||||||
|
//
|
||||||
|
// The types mirror Jellyfin's `BaseItemDto` rather than reshaping it, because the sidecar's `/_officer/*`
|
||||||
|
// façade is a pass-through for the item shapes themselves — what it adds is the user id, the Fields list and
|
||||||
|
// the playback negotiation, not a different vocabulary. Every field here is optional in Jellyfin's own schema
|
||||||
|
// and is optional here too: an Episode has no `CollectionType`, a Movie has no `SeriesId`, and a library
|
||||||
|
// mid-scan can return an item with nothing but an Id.
|
||||||
|
//
|
||||||
|
// VIDEO ONLY. The owner's music is Officer's own /music player, and this machine runs separate Jellyfin
|
||||||
|
// instances for albums and DJ sets; the sidecar already filters music libraries out of `/views` and `/home`.
|
||||||
|
|
||||||
|
export const JELLYFIN_SECTIONS = [
|
||||||
|
{ id: 'home', label: 'Home' },
|
||||||
|
{ id: 'library', label: 'Library' },
|
||||||
|
{ id: 'search', label: 'Search' },
|
||||||
|
{ id: 'settings', label: 'Connection' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type JellyfinSectionId = (typeof JELLYFIN_SECTIONS)[number]['id'];
|
||||||
|
|
||||||
|
/** Where /jellyfin lands, and where an unrecognised section redirects to. */
|
||||||
|
export const DEFAULT_JELLYFIN_SECTION: JellyfinSectionId = 'home';
|
||||||
|
|
||||||
|
export const isJellyfinSection = (value: string | undefined): value is JellyfinSectionId =>
|
||||||
|
JELLYFIN_SECTIONS.some((s) => s.id === value);
|
||||||
|
|
||||||
|
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
|
||||||
|
export const jellyfinSectionPath = (id: JellyfinSectionId) => `/jellyfin/${id}`;
|
||||||
|
|
||||||
|
/** Which library the browse grid is showing — `?view=<id>`. */
|
||||||
|
export const VIEW_PARAM = 'view';
|
||||||
|
/** Which item is open in the detail sheet — `?item=<id>`. */
|
||||||
|
export const ITEM_PARAM = 'item';
|
||||||
|
/** Which item is playing — `?play=<id>`. Separate from `item` so closing the player returns to the detail. */
|
||||||
|
export const PLAY_PARAM = 'play';
|
||||||
|
/** The search text — `?q=…`. */
|
||||||
|
export const QUERY_PARAM = 'q';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The href that opens an item, keeping the page you opened it from.
|
||||||
|
*
|
||||||
|
* Selection is a query parameter rather than a route because the detail sheet is an overlay ON the grid: the
|
||||||
|
* grid stays mounted and scrolled behind it, and closing is dropping one parameter. Building the href from the
|
||||||
|
* CURRENT params is what makes that work in both directions — open an episode from a search and closing it
|
||||||
|
* puts the search back, unchanged.
|
||||||
|
*/
|
||||||
|
export function itemHref(pathname: string, params: URLSearchParams, id: string, play = false): string {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.set(play ? PLAY_PARAM : ITEM_PARAM, id);
|
||||||
|
if (play) next.delete(ITEM_PARAM);
|
||||||
|
return `${pathname}?${next}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The href that closes whichever overlay is open, leaving everything else about the page alone. */
|
||||||
|
export function closeHref(pathname: string, params: URLSearchParams, which: typeof ITEM_PARAM | typeof PLAY_PARAM) {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.delete(which);
|
||||||
|
const qs = next.toString();
|
||||||
|
return qs ? `${pathname}?${qs}` : pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type UserItemData = {
|
||||||
|
PlaybackPositionTicks?: number;
|
||||||
|
PlayCount?: number;
|
||||||
|
IsFavorite?: boolean;
|
||||||
|
Played?: boolean;
|
||||||
|
PlayedPercentage?: number | null;
|
||||||
|
UnplayedItemCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaStream = {
|
||||||
|
Type?: string;
|
||||||
|
Index?: number;
|
||||||
|
Codec?: string | null;
|
||||||
|
Language?: string | null;
|
||||||
|
DisplayTitle?: string | null;
|
||||||
|
Height?: number | null;
|
||||||
|
Width?: number | null;
|
||||||
|
Channels?: number | null;
|
||||||
|
IsDefault?: boolean;
|
||||||
|
IsForced?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaSource = {
|
||||||
|
Id?: string;
|
||||||
|
Name?: string | null;
|
||||||
|
Container?: string | null;
|
||||||
|
Size?: number | null;
|
||||||
|
RunTimeTicks?: number | null;
|
||||||
|
Bitrate?: number | null;
|
||||||
|
MediaStreams?: MediaStream[];
|
||||||
|
SupportsDirectPlay?: boolean;
|
||||||
|
SupportsDirectStream?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Person = { Id?: string; Name?: string; Role?: string | null; Type?: string; PrimaryImageTag?: string };
|
||||||
|
|
||||||
|
export type JellyItem = {
|
||||||
|
Id: string;
|
||||||
|
Name?: string;
|
||||||
|
Type?: string;
|
||||||
|
CollectionType?: string | null;
|
||||||
|
ProductionYear?: number | null;
|
||||||
|
PremiereDate?: string | null;
|
||||||
|
EndDate?: string | null;
|
||||||
|
Status?: string | null;
|
||||||
|
Overview?: string | null;
|
||||||
|
Taglines?: string[];
|
||||||
|
Genres?: string[];
|
||||||
|
OfficialRating?: string | null;
|
||||||
|
CommunityRating?: number | null;
|
||||||
|
CriticRating?: number | null;
|
||||||
|
RunTimeTicks?: number | null;
|
||||||
|
IndexNumber?: number | null;
|
||||||
|
ParentIndexNumber?: number | null;
|
||||||
|
SeriesId?: string | null;
|
||||||
|
SeriesName?: string | null;
|
||||||
|
SeasonId?: string | null;
|
||||||
|
SeasonName?: string | null;
|
||||||
|
ParentId?: string | null;
|
||||||
|
ChildCount?: number | null;
|
||||||
|
RecursiveItemCount?: number | null;
|
||||||
|
PrimaryImageAspectRatio?: number | null;
|
||||||
|
ImageTags?: Record<string, string>;
|
||||||
|
BackdropImageTags?: string[];
|
||||||
|
ParentBackdropItemId?: string | null;
|
||||||
|
ParentBackdropImageTags?: string[];
|
||||||
|
SeriesPrimaryImageTag?: string | null;
|
||||||
|
Studios?: { Id?: string; Name?: string }[];
|
||||||
|
People?: Person[];
|
||||||
|
MediaSources?: MediaSource[];
|
||||||
|
MediaStreams?: MediaStream[];
|
||||||
|
UserData?: UserItemData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemsResponse = { Items?: JellyItem[]; TotalRecordCount?: number };
|
||||||
|
|
||||||
|
export type HomeResponse = {
|
||||||
|
views: JellyItem[];
|
||||||
|
resume: JellyItem[];
|
||||||
|
nextUp: JellyItem[];
|
||||||
|
latest: { viewId: string; viewName: string; collectionType: string | null; items: JellyItem[] }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What `/_officer/items/{id}/playback` answers — the sidecar's own shape, not Jellyfin's. */
|
||||||
|
export type PlaybackPlan = {
|
||||||
|
playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode';
|
||||||
|
url: string;
|
||||||
|
/** A direct file seeks in the browser; a live transcode does not, and the player restarts it at an offset. */
|
||||||
|
seekable: boolean;
|
||||||
|
hlsUrl: string | null;
|
||||||
|
playSessionId: string | null;
|
||||||
|
mediaSourceId: string | null;
|
||||||
|
mediaSource: MediaSource;
|
||||||
|
startSeconds: number;
|
||||||
|
runtimeSeconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the escape hatch
|
||||||
|
// userMiddleware already supports for <img>/<audio>/<video>, and the one the music and photos apps use. These
|
||||||
|
// point at `/_jf`, the authenticated byte pass-through, which keeps Jellyfin's own path shapes: an HLS
|
||||||
|
// playlist references its segments relatively, so any renaming here would mean rewriting m3u8 bodies.
|
||||||
|
|
||||||
|
export const JELLYFIN_BYTES = '/api/jellyfin/_jf';
|
||||||
|
|
||||||
|
const withToken = (url: string, token: string | null) =>
|
||||||
|
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
|
||||||
|
|
||||||
|
/** A `/_jf/...` path from a playback plan, made absolute and authenticated for a `<video>` element. */
|
||||||
|
export const streamUrl = (path: string, token: string | null): string => withToken(`/api/jellyfin${path}`, token);
|
||||||
|
|
||||||
|
type ImageKind = 'Primary' | 'Backdrop' | 'Thumb' | 'Logo';
|
||||||
|
|
||||||
|
type ImageOptions = { tag?: string | null; maxWidth?: number; maxHeight?: number; index?: number };
|
||||||
|
|
||||||
|
export function imageUrl(id: string, kind: ImageKind, token: string | null, options: ImageOptions = {}): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
// The tag is the image's content hash. Including it is what makes these URLs safely cacheable forever —
|
||||||
|
// without it, a replaced poster keeps showing the old one until the browser cache expires.
|
||||||
|
if (options.tag) params.set('tag', options.tag);
|
||||||
|
if (options.maxWidth) params.set('maxWidth', String(options.maxWidth));
|
||||||
|
if (options.maxHeight) params.set('maxHeight', String(options.maxHeight));
|
||||||
|
params.set('quality', '90');
|
||||||
|
const suffix = options.index != null ? `/${options.index}` : '';
|
||||||
|
return withToken(`${JELLYFIN_BYTES}/Items/${id}/Images/${kind}${suffix}?${params}`, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poster for a grid tile, falling back the way Jellyfin's own clients do.
|
||||||
|
*
|
||||||
|
* An episode usually has no poster of its own — it has a Thumb, and its series has the Primary. Falling back
|
||||||
|
* to the series poster is what stops a season view from being a wall of grey rectangles.
|
||||||
|
*/
|
||||||
|
export function posterUrl(item: JellyItem, token: string | null, maxWidth = 400): string | null {
|
||||||
|
const primary = item.ImageTags?.Primary;
|
||||||
|
if (primary) return imageUrl(item.Id, 'Primary', token, { tag: primary, maxWidth });
|
||||||
|
if (item.SeriesPrimaryImageTag && item.SeriesId) {
|
||||||
|
return imageUrl(item.SeriesId, 'Primary', token, { tag: item.SeriesPrimaryImageTag, maxWidth });
|
||||||
|
}
|
||||||
|
const thumb = item.ImageTags?.Thumb;
|
||||||
|
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wide still for a Continue Watching card: the episode's own thumb, else a backdrop, else the poster. */
|
||||||
|
export function thumbUrl(item: JellyItem, token: string | null, maxWidth = 520): string | null {
|
||||||
|
const thumb = item.ImageTags?.Thumb;
|
||||||
|
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
|
||||||
|
const backdrop = item.BackdropImageTags?.[0];
|
||||||
|
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
|
||||||
|
const parentBackdrop = item.ParentBackdropImageTags?.[0];
|
||||||
|
if (parentBackdrop && item.ParentBackdropItemId) {
|
||||||
|
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
|
||||||
|
}
|
||||||
|
return posterUrl(item, token, maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backdropUrl(item: JellyItem, token: string | null, maxWidth = 1600): string | null {
|
||||||
|
const backdrop = item.BackdropImageTags?.[0];
|
||||||
|
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
|
||||||
|
const parentBackdrop = item.ParentBackdropImageTags?.[0];
|
||||||
|
if (parentBackdrop && item.ParentBackdropItemId) {
|
||||||
|
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
|
||||||
|
export const ticksToSeconds = (ticks: number | null | undefined): number => (ticks ? ticks / TICKS_PER_SECOND : 0);
|
||||||
|
|
||||||
|
/** Runtime as "1h 42m" / "48m". Blank rather than "0m" when unknown — a wrong number reads as a real one. */
|
||||||
|
export function formatRuntime(ticks: number | null | undefined): string {
|
||||||
|
const total = Math.round(ticksToSeconds(ticks) / 60);
|
||||||
|
if (!total) return '';
|
||||||
|
const hours = Math.floor(total / 60);
|
||||||
|
const minutes = total % 60;
|
||||||
|
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clock time for the player scrubber — "12:34" under an hour, "1:02:03" over it. */
|
||||||
|
export function formatClock(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||||
|
const total = Math.floor(seconds);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
if (!bytes) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatShortDate = (iso: string | null | undefined): string =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
|
||||||
|
|
||||||
|
/** How far through an item is, 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 { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared';
|
||||||
export type { PhotosSectionId } from './apps/Photos/shared';
|
export type { PhotosSectionId } from './apps/Photos/shared';
|
||||||
|
|
||||||
|
// Same for /jellyfin.
|
||||||
|
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
|
||||||
|
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
|
||||||
|
|
||||||
// Same for /transmission.
|
// Same for /transmission.
|
||||||
export {
|
export {
|
||||||
DEFAULT_TRANSMISSION_SECTION,
|
DEFAULT_TRANSMISSION_SECTION,
|
||||||
|
|||||||
Reference in New Issue
Block a user