jellyfin: subtitle tracks, audio picker and real player controls

the sidecar now describes every audio and subtitle stream on the chosen source. a subtitle
jellyfin can deliver as an external file gets a /_jf webvtt path with the embedded ApiKey
stripped; one it cannot gets the reason instead, so the picker can show it disabled rather
than pretend it does not exist. colliding labels get their stream index — two tracks called
"English - ASS" are a coin flip otherwise.

quality is a named rung rather than a number. a bitrate the owner picks maps onto the rung
at or below it, which is what stops jellyfin's ResolutionNormalizer from inventing a
resolution from an unrounded number. auto stays absent so a copyable source still copies.

the control bar is ours in all three transports. progressive has no timeline the browser can
render, and an audio-track or quality change is a re-negotiation with the server rather than
something a <video> knows how to do — one bar for all three is what keeps those from being
three different players. the menus are hand-rolled because a radix dropdown portals to
document.body, which is outside the fullscreen element and would be invisible exactly when
the player is most likely to be used.

no scrubber thumbnails: they come from trickplay tiles and this server generates none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:10:44 +00:00
co-authored by Claude Opus 5
parent 7732ca1da5
commit 7884bd3290
5 changed files with 842 additions and 81 deletions
+151 -8
View File
@@ -190,12 +190,24 @@ async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
type SourceStream = {
Type?: string;
Index?: number;
Codec?: string;
Profile?: string;
Level?: number;
Width?: number;
Height?: number;
BitRate?: number;
Channels?: number;
Language?: string | null;
DisplayTitle?: string | null;
Title?: string | null;
IsDefault?: boolean;
IsForced?: boolean;
IsExternal?: boolean;
IsTextSubtitleStream?: boolean;
/** Jellyfin's own verdict for THIS profile: External (a file we can fetch), Embed, Encode, or None. */
DeliveryMethod?: string | null;
DeliveryUrl?: string | null;
};
type MediaSource = {
@@ -238,10 +250,24 @@ const COPY_CEILING_BITRATE = 120_000_000;
const ENCODE_BITRATE = 12_000_000;
const ENCODE_MAX_WIDTH = 1920;
/**
* The rungs the quality picker offers, widest first. A bitrate alone is not a quality: Jellyfin's own
* `ResolutionNormalizer` would pair a low one with a resolution of its choosing, and the whole 416-pixel
* incident was that mapping being applied to a number nobody meant. Naming both here is what makes
* "720p" mean 720p.
*/
export const QUALITY_RUNGS = [
{ label: '1080p', videoBitrate: 12_000_000, maxWidth: 1920 },
{ label: '720p', videoBitrate: 6_000_000, maxWidth: 1280 },
{ label: '480p', videoBitrate: 2_500_000, maxWidth: 854 },
{ label: '360p', videoBitrate: 1_000_000, maxWidth: 640 },
] as const;
/**
* Decide the bitrate and resolution cap to ask for, from what is actually in the file.
*
* The two cases pull in opposite directions, which is why this is not one constant:
* With no `requested` ceiling — the "Auto" the player sends by default — the two cases pull in opposite
* directions, which is why this is not one constant:
*
* video can be copied → any cap is a reason for Jellyfin to REFUSE the copy (it will not copy a stream
* above the requested bitrate, or wider than a requested `maxWidth`), so ask for a
@@ -251,8 +277,18 @@ const ENCODE_MAX_WIDTH = 1920;
* CPU; downscaling to 1080p is what makes that keep up with playback.
*
* The copy test mirrors the profile's own conditions rather than a new opinion: h264, not 10-bit, level ≤ 5.2.
*
* A `requested` ceiling overrides both, and deliberately defeats the copy: picking 480p on a file that would
* have passed through untouched is asking for a re-encode, and that is the entire point of picking it.
*/
function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth?: number } {
export type TranscodeQuality = { videoBitrate: number; maxWidth?: number };
function transcodeQuality(source: MediaSource, requested?: number): TranscodeQuality {
if (requested && requested < COPY_CEILING_BITRATE) {
const rung = QUALITY_RUNGS.find((r) => r.videoBitrate <= requested) ?? QUALITY_RUNGS[QUALITY_RUNGS.length - 1]!;
return { videoBitrate: rung.videoBitrate, maxWidth: rung.maxWidth };
}
const video = source.MediaStreams?.find((stream) => stream.Type === 'Video');
const copyable =
video?.Codec?.toLowerCase() === 'h264' && video.Profile?.toLowerCase() !== 'high 10' && (video.Level ?? 0) <= 52;
@@ -261,6 +297,99 @@ function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth
return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH };
}
/** `DisplayTitle` is what Jellyfin shows in its own picker; the rest is a legible fallback when it is absent. */
function trackLabel(stream: SourceStream, fallback: string): string {
const parts = [stream.DisplayTitle || stream.Title, stream.Language, stream.Codec?.toUpperCase()].filter(Boolean);
return (parts[0] as string) || parts.join(' · ') || fallback;
}
export type AudioTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
channels: number | null;
isDefault: boolean;
};
export type SubtitleTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
isDefault: boolean;
isForced: boolean;
/** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */
url: string | null;
/** Why `url` is null, in words the picker can show. Null when the track is fine. */
unavailable: string | null;
};
/**
* Turn the source's streams into the two pickers the player offers.
*
* Subtitles are the interesting half. Jellyfin has already decided, for OUR profile, how each one could be
* delivered, and `DeliveryMethod: 'External'` with a `DeliveryUrl` means "fetch this as a file" — which for
* the profile's declared formats is WebVTT the `<track>` element reads directly. That is the only method
* taken. `Encode` means burning the subtitle into the picture, which forces a full video re-encode of a file
* that may have needed nothing but a remux, so an image-based track (PGS, VOBSUB) is reported as unavailable
* WITH ITS REASON rather than quietly dropped — "why is this subtitle missing" should be answerable from the
* picker instead of from the server logs.
*/
function describeTracks(source: MediaSource): { audio: AudioTrack[]; subtitles: SubtitleTrack[] } {
const streams = source.MediaStreams ?? [];
const audio = streams
.filter((stream) => stream.Type === 'Audio' && stream.Index !== undefined)
.map((stream) => ({
index: stream.Index!,
label: trackLabel(stream, `Track ${stream.Index}`),
language: stream.Language ?? null,
codec: stream.Codec ?? null,
channels: stream.Channels ?? null,
isDefault: stream.IsDefault === true,
}));
const subtitles = streams
.filter((stream) => stream.Type === 'Subtitle' && stream.Index !== undefined)
.map((stream) => {
const external = stream.DeliveryMethod === 'External' && !!stream.DeliveryUrl;
const graphical = stream.IsTextSubtitleStream === false;
return {
index: stream.Index!,
label: trackLabel(stream, `Subtitle ${stream.Index}`),
language: stream.Language ?? null,
codec: stream.Codec ?? null,
isDefault: stream.IsDefault === true,
isForced: stream.IsForced === true,
url: external ? `/_jf${sanitizeUpstreamPath(stream.DeliveryUrl!)}` : null,
unavailable: external
? null
: graphical
? 'image-based subtitles can only be burned into the video, which forces a full re-encode'
: `the server offered no external file for this track (delivery: ${stream.DeliveryMethod ?? 'none'})`,
};
});
return { audio: disambiguate(audio), subtitles: disambiguate(subtitles) };
}
/**
* Make every label in a list distinguishable.
*
* Two subtitle tracks on the same file genuinely do come back as "English - ASS" and "English - ASS" — one is
* usually dialogue and the other signs, and Jellyfin's own `DisplayTitle` does not say which. A picker with
* two identical rows is a coin flip, so colliding labels get their stream index, which at least makes the
* choice repeatable.
*/
function disambiguate<T extends { index: number; label: string }>(tracks: T[]): T[] {
const counts = new Map<string, number>();
for (const track of tracks) counts.set(track.label, (counts.get(track.label) ?? 0) + 1);
return tracks.map((track) =>
counts.get(track.label)! > 1 ? { ...track, label: `${track.label} #${track.index}` } : track,
);
}
/**
* Fix up the HLS URL Jellyfin handed back so it carries the same quality decision as the progressive one.
*
@@ -275,11 +404,10 @@ function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth
* Verified against a real answer from this server: the URL comes back with `VideoBitrate=119616000` and no
* width, on an item whose transcode reason was `ContainerNotSupported` alone.
*/
function hlsPath(source: MediaSource): string {
function hlsPath(source: MediaSource, quality: TranscodeQuality): string {
const sanitized = sanitizeUpstreamPath(source.TranscodingUrl!);
const [path, search = ''] = sanitized.split('?');
const params = new URLSearchParams(search);
const quality = transcodeQuality(source);
// Jellyfin's own casing here is PascalCase, and its query parsing is case-insensitive — but a duplicate key
// under a different case would still be two values, so delete before setting rather than trusting that.
@@ -315,8 +443,15 @@ function hlsPath(source: MediaSource): string {
* `scale=…min(max(iw,ih*a),416)…` with `-maxrate 0`, i.e. 416 pixels wide. That is the whole of the "why does
* it look so bad" question, and it is a silent, plausible-looking picture rather than an error.
*/
function progressivePath(id: string, source: MediaSource, playSessionId: string | null, startSeconds: number): string {
const quality = transcodeQuality(source);
type ProgressiveParams = {
id: string;
source: MediaSource;
playSessionId: string | null;
startSeconds: number;
quality: TranscodeQuality;
};
function progressivePath({ id, source, playSessionId, startSeconds, quality }: ProgressiveParams): string {
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
static: 'false',
container: 'mp4',
@@ -389,6 +524,7 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
const playSessionId = info.PlaySessionId ?? null;
const direct = !!(source.SupportsDirectPlay || source.SupportsDirectStream);
const quality = transcodeQuality(source, body.maxStreamingBitrate);
const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
static: 'true',
@@ -403,7 +539,7 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
: // A `TranscodingUrl` is Jellyfin agreeing to transcode. We ask for the same thing progressively
// instead of taking its HLS URL, but its absence still means "refused", so it stays the signal.
source.TranscodingUrl
? progressivePath(id, source, playSessionId, startSeconds)
? progressivePath({ id, source, playSessionId, startSeconds, quality })
: null;
if (!path) {
@@ -419,12 +555,19 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
seekable: direct,
// The seekable form of a transcode: the playlist spans the whole runtime, so a scrub is a segment request
// rather than a new ffmpeg. Preferred by the player whenever it is present and MSE is available.
hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source)}` : null,
hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source, quality)}` : null,
playSessionId,
mediaSourceId: source.Id ?? null,
mediaSource: source,
startSeconds,
runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
// The pickers. Audio needs a re-negotiation to change (it is muxed into the stream); subtitles do not,
// because an external VTT is a separate file the <track> element fetches on its own.
...describeTracks(source),
audioStreamIndex: body.audioStreamIndex ?? null,
/** Null means Auto — the copy-or-encode decision above, rather than a rung the owner picked. */
maxStreamingBitrate: body.maxStreamingBitrate ?? null,
qualityRungs: QUALITY_RUNGS,
});
}
@@ -0,0 +1,404 @@
import type { AudioTrack, QualityRung, SubtitleTrack } from './shared';
import { useEffect, useRef, useState } from 'react';
import {
Check,
Gauge,
Languages,
Maximize2,
Minimize2,
Pause,
Play,
RotateCcw,
SkipForward,
Subtitles,
Volume1,
Volume2,
VolumeX,
} from 'lucide-react';
import { formatClock } from './shared';
// The control bar. Ours rather than the browser's for every transport, so that the progressive fallback — whose
// scrubber has to re-request the stream — is not a visibly different player from the seekable ones.
//
// THE MENUS ARE HAND-ROLLED, and that is not an oversight. A Radix/shadcn dropdown renders through a portal
// into document.body, and body is not inside the element that went fullscreen — so every one of these would be
// invisible exactly when the player is most likely to be used. These stay in the DOM subtree they belong to.
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
const BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white/90 hover:bg-white/15';
const ROW = 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-white/10';
type MenuProps = {
icon: React.ReactNode;
label: string;
active?: boolean;
children: (close: () => void) => React.ReactNode;
};
const Menu = ({ icon, label, active, children }: MenuProps) => {
const [open, setOpen] = useState(false);
const wrapper = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointer = (ev: PointerEvent) => {
if (!wrapper.current?.contains(ev.target as Node)) setOpen(false);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return;
// Escape closes the menu rather than leaving fullscreen, but only while one is open.
ev.stopPropagation();
ev.preventDefault();
setOpen(false);
};
document.addEventListener('pointerdown', onPointer, true);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('pointerdown', onPointer, true);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
return (
<div ref={wrapper} className="relative shrink-0">
<button
type="button"
aria-label={label}
aria-expanded={open}
onClick={() => setOpen((was) => !was)}
className={`${BUTTON} ${active ? 'text-primary' : ''}`}
>
{icon}
</button>
{open && (
<div className="absolute bottom-10 right-0 z-10 max-h-72 min-w-56 overflow-y-auto rounded-lg border border-white/10 bg-black/90 p-1 text-white shadow-xl backdrop-blur">
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-white/40">{label}</p>
{children(() => setOpen(false))}
</div>
)}
</div>
);
};
type OptionProps = { selected: boolean; onSelect: () => void; children: React.ReactNode; hint?: string | null };
const Option = ({ selected, onSelect, children, hint }: OptionProps) => (
<button type="button" onClick={onSelect} className={ROW}>
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100 text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1">
<span className="block truncate">{children}</span>
{hint && <span className="block truncate text-[10px] text-white/40">{hint}</span>}
</span>
</button>
);
export type PlayerControlsProps = {
playing: boolean;
position: number;
duration: number;
/** Seconds buffered ahead of the current position, as one number — the element's own ranges, flattened. */
bufferedTo: number;
/** False in progressive mode, where a scrub costs a new transcode and only commits on release. */
liveScrub: boolean;
volume: number;
muted: boolean;
rate: number;
fullscreen: boolean;
audio: AudioTrack[];
audioIndex: number | null;
subtitles: SubtitleTrack[];
subtitleIndex: number | null;
qualityRungs: QualityRung[];
maxBitrate: number | null;
/** Set while a re-negotiation is in flight, so a picker click reads as busy rather than as nothing. */
busy: boolean;
onTogglePlay: () => void;
onScrub: (seconds: number) => void;
onScrubCommit: (seconds: number) => void;
onSkip: (seconds: number) => void;
onVolume: (volume: number) => void;
onToggleMute: () => void;
onRate: (rate: number) => void;
onAudio: (index: number) => void;
onSubtitle: (index: number | null) => void;
onQuality: (bitrate: number | null) => void;
onFullscreen: () => void;
};
export const PlayerControls = (props: PlayerControlsProps) => {
const { position, duration, bufferedTo, liveScrub } = props;
const barRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<number | null>(null);
const [hover, setHover] = useState<number | null>(null);
const shown = dragging ?? position;
const pct = (seconds: number) => (duration > 0 ? Math.min(100, Math.max(0, (seconds / duration) * 100)) : 0);
const secondsAt = (clientX: number): number => {
const box = barRef.current?.getBoundingClientRect();
if (!box || box.width === 0) return 0;
return Math.max(0, Math.min(duration, ((clientX - box.left) / box.width) * duration));
};
// Pointer capture rather than window listeners: the drag keeps tracking when the cursor leaves the bar, and
// it ends on the same element it started on even if that is over the video or outside the window.
const onPointerDown = (ev: React.PointerEvent<HTMLDivElement>) => {
ev.currentTarget.setPointerCapture(ev.pointerId);
const seconds = secondsAt(ev.clientX);
setDragging(seconds);
if (liveScrub) props.onScrub(seconds);
};
const onPointerMove = (ev: React.PointerEvent<HTMLDivElement>) => {
const seconds = secondsAt(ev.clientX);
setHover(seconds);
if (dragging === null) return;
setDragging(seconds);
// In progressive mode every intermediate value would start an ffmpeg, so only the release commits.
if (liveScrub) props.onScrub(seconds);
};
const onPointerUp = (ev: React.PointerEvent<HTMLDivElement>) => {
if (dragging === null) return;
ev.currentTarget.releasePointerCapture(ev.pointerId);
props.onScrubCommit(secondsAt(ev.clientX));
setDragging(null);
};
const currentSubtitle = props.subtitles.find((track) => track.index === props.subtitleIndex) ?? null;
const currentQuality = props.qualityRungs.find((rung) => rung.videoBitrate === props.maxBitrate) ?? null;
return (
<div className="bg-gradient-to-t from-black/90 to-transparent px-3 pb-2 pt-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-right text-[11px] tabular-nums text-white/70">{formatClock(shown)}</span>
<div
ref={barRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.max(1, Math.round(duration))}
aria-valuenow={Math.round(shown)}
aria-valuetext={formatClock(shown)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={() => setHover(null)}
className="group relative h-6 flex-1 cursor-pointer touch-none select-none"
>
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/20">
<div className="h-full rounded-full bg-white/30" style={{ width: `${pct(bufferedTo)}%` }} />
</div>
<div
className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary"
style={{ width: `${pct(shown)}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary opacity-0 transition-opacity group-hover:opacity-100"
style={{ left: `${pct(shown)}%`, opacity: dragging !== null ? 1 : undefined }}
/>
{hover !== null && duration > 0 && (
<span
className="pointer-events-none absolute -top-5 -translate-x-1/2 rounded bg-black/80 px-1 text-[10px] tabular-nums text-white"
style={{ left: `${pct(hover)}%` }}
>
{formatClock(hover)}
</span>
)}
</div>
<span className="w-12 shrink-0 text-[11px] tabular-nums text-white/70">{formatClock(duration)}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={props.onTogglePlay}
aria-label={props.playing ? 'Pause' : 'Play'}
className={BUTTON}
>
{props.playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" fill="currentColor" />}
</button>
<button type="button" onClick={() => props.onSkip(-10)} aria-label="Back 10 seconds" className={BUTTON}>
<RotateCcw className="h-4 w-4" />
</button>
<button type="button" onClick={() => props.onSkip(30)} aria-label="Forward 30 seconds" className={BUTTON}>
<SkipForward className="h-4 w-4" />
</button>
{/* The slider stays out of the tab order on purpose — the volume button is the keyboard affordance,
and a range input here would swallow the arrow keys the player binds to seeking. */}
<div className="group/vol flex items-center">
<button
type="button"
onClick={props.onToggleMute}
aria-label={props.muted ? 'Unmute' : 'Mute'}
className={BUTTON}
>
{props.muted || props.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : props.volume < 0.5 ? (
<Volume1 className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</button>
<input
type="range"
min={0}
max={1}
step={0.02}
tabIndex={-1}
value={props.muted ? 0 : props.volume}
onChange={(ev) => props.onVolume(Number(ev.target.value))}
aria-label="Volume"
className="h-1 w-0 cursor-pointer accent-primary opacity-0 transition-all group-hover/vol:w-20 group-hover/vol:opacity-100"
/>
</div>
<span className="flex-1" />
{props.busy && <span className="mr-1 text-[10px] text-white/50">switching</span>}
<Menu icon={<Subtitles className="h-4 w-4" />} label="Subtitles" active={props.subtitleIndex !== null}>
{(close) => (
<>
<Option
selected={props.subtitleIndex === null}
onSelect={() => {
props.onSubtitle(null);
close();
}}
>
Off
</Option>
{props.subtitles.length === 0 && (
<p className="px-2 py-1.5 text-[11px] text-white/40">This file has no subtitle tracks.</p>
)}
{props.subtitles.map((track) =>
track.url ? (
<Option
key={track.index}
selected={props.subtitleIndex === track.index}
onSelect={() => {
props.onSubtitle(track.index);
close();
}}
hint={track.isForced ? 'forced' : null}
>
{track.label}
</Option>
) : (
<p
key={track.index}
className="px-2 py-1.5 text-[11px] text-white/30"
title={track.unavailable ?? ''}
>
<span className="block truncate line-through">{track.label}</span>
<span className="block text-[10px]">unavailable {track.unavailable}</span>
</p>
),
)}
</>
)}
</Menu>
<Menu icon={<Languages className="h-4 w-4" />} label="Audio track">
{(close) => (
<>
{props.audio.map((track) => (
<Option
key={track.index}
selected={props.audioIndex === track.index || (props.audioIndex === null && track.isDefault)}
onSelect={() => {
props.onAudio(track.index);
close();
}}
hint={[track.codec?.toUpperCase(), track.channels ? `${track.channels}ch` : null]
.filter(Boolean)
.join(' · ')}
>
{track.label}
</Option>
))}
{props.audio.length <= 1 && (
<p className="px-2 py-1.5 text-[11px] text-white/40">Only one audio track.</p>
)}
</>
)}
</Menu>
<Menu icon={<Gauge className="h-4 w-4" />} label="Speed and quality">
{(close) => (
<>
<p className="px-2 pt-1 text-[10px] uppercase tracking-wide text-white/40">Speed</p>
{PLAYBACK_RATES.map((rate) => (
<Option
key={rate}
selected={props.rate === rate}
onSelect={() => {
props.onRate(rate);
close();
}}
>
{rate === 1 ? 'Normal' : `${rate}×`}
</Option>
))}
<p className="mt-1 border-t border-white/10 px-2 pt-2 text-[10px] uppercase tracking-wide text-white/40">
Quality
</p>
<Option
selected={props.maxBitrate === null}
onSelect={() => {
props.onQuality(null);
close();
}}
hint="direct play or remux where possible"
>
Auto
</Option>
{props.qualityRungs.map((rung) => (
<Option
key={rung.videoBitrate}
selected={props.maxBitrate === rung.videoBitrate}
onSelect={() => {
props.onQuality(rung.videoBitrate);
close();
}}
hint={`${Math.round(rung.videoBitrate / 1_000_000)} Mbit · forces a re-encode`}
>
{rung.label}
</Option>
))}
</>
)}
</Menu>
<button
type="button"
onClick={props.onFullscreen}
aria-label={props.fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
className={BUTTON}
>
{props.fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
{(currentSubtitle || currentQuality) && (
<p className="truncate px-1 text-[10px] text-white/35">
{[
currentSubtitle && `subtitles: ${currentSubtitle.label}`,
currentQuality && `quality: ${currentQuality.label}`,
]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
</div>
);
};
@@ -1,18 +1,11 @@
import type { PlaybackPlan } from './shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Loader2, Maximize2, Pause, Play, X } from 'lucide-react';
import { Loader2, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import {
closeHref,
episodeLabel,
formatClock,
PLAY_PARAM,
streamUrl,
TICKS_PER_SECOND,
ticksToSeconds,
} from './shared';
import { closeHref, episodeLabel, PLAY_PARAM, streamUrl, TICKS_PER_SECOND, ticksToSeconds } from './shared';
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
import { PlayerControls } from './PlayerControls';
import Hls from './vendor/hls.mjs';
// The player — a full-panel overlay opened by `?play=<id>`.
@@ -21,23 +14,33 @@ import Hls from './vendor/hls.mjs';
// file. There are three transports, and `mode` names which one is in use rather than letting the difference
// leak out as mysterious behaviour:
//
// file — direct play or direct stream. A real byte-range resource; native controls, native seeking,
// `currentTime` is the position.
// file — direct play or direct stream. A real byte-range resource; native seeking, `currentTime` is
// the position, and a scrub can follow the pointer.
// hls-mse — a transcode, driven by the vendored hls.js over Media Source Extensions. A real timeline
// too: Jellyfin's playlist is VOD and spans the whole runtime, so a scrub is a segment
// request and the server moves its ffmpeg to that point.
// request and the server moves its ffmpeg to that point. Committed on release, because each
// intermediate position would be a real repositioning of that ffmpeg.
// progressive — the fallback for a browser without MSE. One endless mp4 with no length and no ranges, so
// the browser CANNOT seek it: seeking means asking the server for a NEW stream at an offset,
// the position is `startSeconds + currentTime`, and the scrubber has to be ours.
// and the position is `startSeconds + currentTime`.
//
// Only that last mode is the odd one, and `hasTimeline` is the single flag every other decision here reads.
// `hasTimeline` is the single flag every other decision here reads.
//
// Safari plays an m3u8 natively and is deliberately NOT given one. Segment URIs inside the playlist are
// relative, so they inherit the playlist's path but not its `?token=` — every segment would come back 401,
// and Safari cannot set an Authorization header the way hls.js can. Where hls.js runs (including iOS 17.1+,
// via Managed Media Source) it is used; where it does not, progressive is the honest fallback.
//
// The controls are ours in every mode. Not for looks: progressive has no timeline the browser can render, and
// an audio-track or quality change is a re-negotiation with the server rather than something a <video> knows
// how to do. One control bar for all three is what keeps those from being three different players.
//
// NO SCRUBBER THUMBNAILS. They come from Jellyfin's trickplay tiles, and this server generates none
// (`item.Trickplay` is `{}`) — a hover preview would be a permanently empty box.
const PROGRESS_INTERVAL_MS = 10_000;
const CHROME_HIDE_MS = 2_500;
const VOLUME_KEY = 'jellyfin:volume';
type Mode = 'file' | 'hls-mse' | 'progressive';
@@ -47,6 +50,26 @@ function transportFor(plan: PlaybackPlan): Mode {
return 'progressive';
}
function readStoredVolume(): { volume: number; muted: boolean } {
try {
const raw = localStorage.getItem(VOLUME_KEY);
if (!raw) return { volume: 1, muted: false };
const parsed = JSON.parse(raw) as { volume?: number; muted?: boolean };
return { volume: Math.min(1, Math.max(0, parsed.volume ?? 1)), muted: !!parsed.muted };
} catch {
return { volume: 1, muted: false };
}
}
/** How far the element has buffered past the point being played, flattened to one number for the bar. */
function bufferedAhead(video: HTMLVideoElement): number {
const { buffered, currentTime } = video;
for (let i = 0; i < buffered.length; i += 1) {
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) return buffered.end(i);
}
return currentTime;
}
export const VideoPlayer = ({ id }: { id: string }) => {
const { token } = useClient();
const { pathname } = useLocation();
@@ -55,11 +78,21 @@ export const VideoPlayer = ({ id }: { id: string }) => {
const negotiate = usePlaybackPlan();
const report = usePlaystateReporter();
const shellRef = useRef<HTMLDivElement>(null);
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);
const [bufferedTo, setBufferedTo] = useState(0);
const [playing, setPlaying] = useState(false);
const stored = useMemo(readStoredVolume, []);
const [volume, setVolume] = useState(stored.volume);
const [muted, setMuted] = useState(stored.muted);
const [rate, setRate] = useState(1);
const [subtitleIndex, setSubtitleIndex] = useState<number | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [chrome, setChrome] = useState(true);
// 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
@@ -67,10 +100,15 @@ export const VideoPlayer = ({ id }: { id: string }) => {
const planRef = useRef<PlaybackPlan | null>(null);
const positionRef = useRef(0);
const startedRef = useRef(false);
// The chosen track and rung, kept in refs so `open` does not have to be rebuilt (and the start-once effect
// re-run) every time one of them changes.
const audioRef = useRef<number | null>(null);
const bitrateRef = useRef<number | null>(null);
const mode = useMemo(() => (plan ? transportFor(plan) : null), [plan]);
const hasTimeline = mode !== null && mode !== 'progressive';
const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
const subtitle = plan?.subtitles.find((track) => track.index === subtitleIndex && track.url) ?? null;
const sendReport = useCallback(
(action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
@@ -88,7 +126,10 @@ export const VideoPlayer = ({ id }: { id: string }) => {
[id, report],
);
/** Open (or re-open) the stream at an offset. In progressive mode a seek is exactly this call. */
/**
* Open (or re-open) the stream. In progressive mode a seek is exactly this call; so is any audio-track or
* quality change in every mode, because both are decided during negotiation and baked into the stream.
*/
const open = useCallback(
async (startSeconds: number) => {
// Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
@@ -96,11 +137,17 @@ export const VideoPlayer = ({ id }: { id: string }) => {
if (planRef.current) sendReport('stopped');
setError(null);
try {
const next = await negotiate.mutateAsync({ id, startSeconds });
const next = await negotiate.mutateAsync({
id,
startSeconds,
audioStreamIndex: audioRef.current ?? undefined,
maxStreamingBitrate: bitrateRef.current ?? undefined,
});
planRef.current = next;
positionRef.current = next.startSeconds;
setPlan(next);
setPosition(next.startSeconds);
setBufferedTo(next.startSeconds);
} catch (err) {
setError((err as { message?: string } | null)?.message ?? 'Playback could not be started');
}
@@ -164,30 +211,116 @@ export const VideoPlayer = ({ id }: { id: string }) => {
return () => clearInterval(timer);
}, [plan, sendReport]);
const onTimeUpdate = () => {
// Volume, mute and rate are element properties, not attributes — React will not set them from JSX, and they
// have to be re-applied after the element remounts (which in progressive mode is every scrub).
useEffect(() => {
const video = videoRef.current;
if (!video || !plan) return;
// Only progressive starts the element's clock at zero for a stream that begins mid-film.
const next = hasTimeline ? video.currentTime : plan.startSeconds + video.currentTime;
positionRef.current = next;
setPosition(next);
};
if (!video) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = rate;
}, [volume, muted, rate, plan]);
const seek = (seconds: number) => {
useEffect(() => {
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
}, [volume, muted]);
// Only the selected subtitle is rendered as a <track>, so Jellyfin is asked to extract exactly one — and
// `mode` has to be set imperatively because `default` only applies on the element's first parse.
useEffect(() => {
const video = videoRef.current;
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
if (hasTimeline && video) {
video.currentTime = clamped;
return;
if (!video) return;
for (let i = 0; i < video.textTracks.length; i += 1) {
video.textTracks[i]!.mode = subtitle ? 'showing' : 'disabled';
}
void open(clamped);
};
}, [subtitle, plan]);
const togglePlay = () => {
useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement === shellRef.current);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
// Auto-hide, but never while paused — a paused player with no controls looks broken rather than clean.
useEffect(() => {
if (!chrome || !playing) return;
const timer = setTimeout(() => setChrome(false), CHROME_HIDE_MS);
return () => clearTimeout(timer);
}, [chrome, playing, position]);
const seek = useCallback(
(seconds: number) => {
const video = videoRef.current;
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
if (hasTimeline && video) {
video.currentTime = clamped;
return;
}
void open(clamped);
},
[duration, hasTimeline, open],
);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) void video.play().catch(() => undefined);
else video.pause();
}, []);
const toggleFullscreen = useCallback(() => {
// The container, not the <video> — fullscreening the element itself would take the controls with it into
// a UA-drawn shell that has none of these buttons.
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
else void shellRef.current?.requestFullscreen().catch(() => undefined);
}, []);
const changeAudio = (index: number) => {
audioRef.current = index;
void open(positionRef.current);
};
const changeQuality = (bitrate: number | null) => {
bitrateRef.current = bitrate;
void open(positionRef.current);
};
const wake = () => setChrome(true);
// Keyboard on the container rather than on window: the player is a panel inside a workspace, and stealing
// space or the arrow keys from whatever else is on screen would be wrong. Autofocus puts it in reach.
const onKeyDown = (ev: React.KeyboardEvent) => {
const target = ev.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
const bump = (delta: number) => setVolume((was) => Math.min(1, Math.max(0, was + delta)));
const keys: Record<string, () => void> = {
' ': togglePlay,
k: togglePlay,
ArrowLeft: () => seek(positionRef.current - 10),
j: () => seek(positionRef.current - 10),
ArrowRight: () => seek(positionRef.current + 10),
l: () => seek(positionRef.current + 10),
ArrowUp: () => bump(0.05),
ArrowDown: () => bump(-0.05),
m: () => setMuted((was) => !was),
f: toggleFullscreen,
c: () => setSubtitleIndex((was) => (was !== null ? null : (plan?.subtitles.find((s) => s.url)?.index ?? null))),
};
const action = keys[ev.key];
if (!action) return;
ev.preventDefault();
wake();
action();
};
const onTimeUpdate = () => {
const video = videoRef.current;
if (!video || !plan) return;
// Only progressive starts the element's clock at zero for a stream that begins mid-film.
const offset = hasTimeline ? 0 : plan.startSeconds;
positionRef.current = offset + video.currentTime;
setPosition(offset + video.currentTime);
setBufferedTo(offset + bufferedAhead(video));
};
// `src` for everything except MSE, where hls.js feeds the element instead. The key remounts the element when
@@ -196,10 +329,25 @@ export const VideoPlayer = ({ id }: { id: string }) => {
const close = closeHref(pathname, params, PLAY_PARAM);
const title = item ? episodeLabel(item) : '';
const idle = playing && !chrome;
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">
<div
ref={shellRef}
tabIndex={-1}
autoFocus
onKeyDown={onKeyDown}
onPointerMove={wake}
onPointerDown={wake}
className={`absolute inset-0 z-30 bg-black outline-none ${idle ? 'cursor-none' : ''}`}
>
{/* Chrome floats OVER the video rather than sitting beside it in a column, so that going fullscreen —
which fullscreens this container — does not change the video's size or the layout around it. */}
<div
className={`absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-gradient-to-b from-black/80 to-transparent px-3 py-2 text-white transition-opacity ${
idle ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<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">
@@ -215,7 +363,7 @@ export const VideoPlayer = ({ id }: { id: string }) => {
</Link>
</div>
<div className="relative flex-1 bg-black">
<div className="absolute inset-0 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>
@@ -228,23 +376,41 @@ export const VideoPlayer = ({ id }: { id: string }) => {
src={source ?? undefined}
className="h-full w-full"
autoPlay
// A real timeline means the browser's own controls are correct, and better than ours. Progressive
// has none, and native controls there would show a scrubber that does nothing.
controls={hasTimeline}
playsInline
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onTimeUpdate={onTimeUpdate}
onProgress={onTimeUpdate}
onPlay={() => {
setPaused(false);
setPlaying(true);
sendReport('playing');
}}
onPause={() => {
setPaused(true);
setPlaying(false);
setChrome(true);
sendReport('progress', true);
}}
onEnded={() => sendReport('stopped')}
onEnded={() => {
setPlaying(false);
sendReport('stopped');
}}
// hls.js reports its own errors through the ERROR event, and sets a MediaError on the element for
// ones it is already recovering from — so in MSE mode this handler would fire on the recoverable.
onError={() => mode !== 'hls-mse' && setError('The stream stopped unexpectedly.')}
/>
>
{/* A <track> cannot send headers, so this is the `?token=` form. Safe here in a way an m3u8 is
not: it is one self-contained file with no relative children to lose the query string. */}
{subtitle && (
<track
key={subtitle.index}
kind="subtitles"
src={streamUrl(subtitle.url!, token)}
srcLang={subtitle.language ?? undefined}
label={subtitle.label}
default
/>
)}
</video>
) : (
<div className="flex h-full items-center justify-center text-white/60">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -252,35 +418,44 @@ export const VideoPlayer = ({ id }: { id: string }) => {
)}
</div>
{plan && !hasTimeline && !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"
{plan && !error && (
<div
className={`absolute inset-x-0 bottom-0 z-10 transition-opacity ${
idle ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<PlayerControls
playing={playing}
position={position}
duration={duration}
bufferedTo={bufferedTo}
liveScrub={mode === 'file'}
volume={volume}
muted={muted}
rate={rate}
fullscreen={fullscreen}
audio={plan.audio}
audioIndex={plan.audioStreamIndex}
subtitles={plan.subtitles}
subtitleIndex={subtitleIndex}
qualityRungs={plan.qualityRungs}
maxBitrate={plan.maxStreamingBitrate}
busy={negotiate.isPending}
onTogglePlay={togglePlay}
onScrub={seek}
onScrubCommit={seek}
onSkip={(delta) => seek(positionRef.current + delta)}
onVolume={(next) => {
setVolume(next);
setMuted(next === 0);
}}
onToggleMute={() => setMuted((was) => !was)}
onRate={setRate}
onAudio={changeAudio}
onSubtitle={setSubtitleIndex}
onQuality={changeQuality}
onFullscreen={toggleFullscreen}
/>
<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>
@@ -144,6 +144,30 @@ export type HomeResponse = {
latest: { viewId: string; viewName: string; collectionType: string | null; items: JellyItem[] }[];
};
export type AudioTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
channels: number | null;
isDefault: boolean;
};
export type SubtitleTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
isDefault: boolean;
isForced: boolean;
/** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */
url: string | null;
/** Why `url` is null, in words the picker shows. Null when the track is fine. */
unavailable: string | null;
};
export type QualityRung = { label: string; videoBitrate: number; maxWidth: number };
/** What `/_officer/items/{id}/playback` answers — the sidecar's own shape, not Jellyfin's. */
export type PlaybackPlan = {
playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode';
@@ -156,6 +180,13 @@ export type PlaybackPlan = {
mediaSource: MediaSource;
startSeconds: number;
runtimeSeconds: number | null;
audio: AudioTrack[];
subtitles: SubtitleTrack[];
/** Echoed back so the picker shows what is actually playing, not what was last clicked. */
audioStreamIndex: number | null;
/** Null means Auto — the server's own copy-or-encode decision rather than a rung the owner picked. */
maxStreamingBitrate: number | null;
qualityRungs: QualityRung[];
};
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
@@ -131,7 +131,14 @@ export function useItemFlags() {
// ── Playback ──────────────────────────────────────────────────────────────────────────────────────
export type PlaybackRequest = { id: string; startSeconds: number; mediaSourceId?: string; audioStreamIndex?: number };
export type PlaybackRequest = {
id: string;
startSeconds: number;
mediaSourceId?: string;
audioStreamIndex?: number;
/** A rung the owner picked. Omitted means Auto — the server decides whether it can copy the video. */
maxStreamingBitrate?: number;
};
/**
* Negotiate a stream. A mutation rather than a query on purpose: it has a side effect on the server —
@@ -141,10 +148,11 @@ export type PlaybackRequest = { id: string; startSeconds: number; mediaSourceId?
export function usePlaybackPlan() {
const { post } = useClient();
return useMutation({
mutationFn: ({ id, startSeconds, mediaSourceId, audioStreamIndex }: PlaybackRequest) =>
mutationFn: ({ id, startSeconds, mediaSourceId, audioStreamIndex, maxStreamingBitrate }: PlaybackRequest) =>
post<PlaybackPlan>(`/jellyfin/_officer/items/${id}/playback${query({ startSeconds })}`, {
mediaSourceId,
audioStreamIndex,
maxStreamingBitrate,
}),
});
}