Files
jellyfin/web/VideoPlayer.tsx
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

507 lines
22 KiB
TypeScript

import type { PlaybackPlan } from './shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Loader2, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
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>`.
//
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: what the server hands back is not always a
// 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 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. 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,
// and the position is `startSeconds + currentTime`.
//
// `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;
/** Manhattan pixels a pointer must travel before it counts as the user, rather than as a resting mouse. */
const WAKE_THRESHOLD_PX = 8;
const VOLUME_KEY = 'jellyfin:volume';
type Mode = 'file' | 'hls-mse' | 'progressive';
function transportFor(plan: PlaybackPlan): Mode {
if (plan.seekable) return 'file';
if (plan.hlsUrl && Hls.isSupported()) return 'hls-mse';
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();
const [params] = useSearchParams();
const { data: item } = useJellyfinItem(id);
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 [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
// 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 idRef = useRef(id);
idRef.current = id;
// React Query hands back a new mutation object each render, so this is the same story as `report` below:
// keeping `open` stable is what keeps the start-once effect from re-running on every frame of playback.
const negotiateRef = useRef(negotiate);
negotiateRef.current = negotiate;
// 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);
// Where the pointer was when it last counted as movement — see `onPointerMove`.
const pointerRef = useRef<{ x: number; y: 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;
// `report` is a NEW function on every render — `useClient()` rebuilds its verbs each time — so it is held in
// a ref rather than named as a dependency. This is not a micro-optimisation: an unstable `sendReport` made
// the "report where playback stopped" cleanup below re-run on every render, which nulled `planRef` a few
// milliseconds after the stream opened and turned every later report into a no-op. Nothing ever reached
// Jellyfin, so nothing ever appeared in Continue Watching.
const reportRef = useRef(report);
reportRef.current = report;
const sendReport = useCallback((action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
const current = planRef.current;
if (!current) return;
reportRef.current(action, {
ItemId: idRef.current,
PlaySessionId: current.playSessionId,
MediaSourceId: current.mediaSourceId,
PositionTicks: Math.max(0, Math.round(positionRef.current * TICKS_PER_SECOND)),
IsPaused: isPaused,
CanSeek: current.seekable,
});
}, []);
/**
* 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
// Jellyfin shows two sessions playing the same thing.
if (planRef.current) sendReport('stopped');
setError(null);
try {
const next = await negotiateRef.current.mutateAsync({
id: idRef.current,
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');
}
},
[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]);
// hls.js owns the element's buffer in `hls-mse` mode, so the element gets no `src` at all — attaching one
// alongside MSE is how you get two sources fighting over the same video.
//
// The credential goes on as a header rather than in the URL because hls.js fetches every segment itself:
// segment URIs are RELATIVE to the playlist, so a `?token=` on the playlist would not survive onto them.
// `xhrSetup` runs before hls.js opens the request and it skips its own `open` if one already happened,
// which is what makes setting a header here possible at all.
useEffect(() => {
const video = videoRef.current;
if (mode !== 'hls-mse' || !plan?.hlsUrl || !video) return;
const hls = new Hls({
startPosition: plan.startSeconds > 0 ? plan.startSeconds : -1,
xhrSetup: (xhr, url) => {
xhr.open('GET', url, true);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
},
});
// Non-fatal errors are hls.js's normal weather and it recovers from them itself. A fatal one is worth one
// attempt at the documented recovery for its kind before giving the player back to the user as broken.
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else setError('The stream stopped unexpectedly.');
});
hls.loadSource(`/api/jellyfin${plan.hlsUrl}`);
hls.attachMedia(video);
return () => hls.destroy();
}, [mode, plan, token]);
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
// This must run exactly once, at unmount: the cleanup nulls `planRef`, so anything that made it re-run
// mid-playback would silently switch off every report that follows. `sendReport` is `useCallback(…, [])`
// and never changes identity, which is what makes naming it here equivalent to an empty list — and is why
// it has to stay that way. If it ever grows a dependency, this becomes the Continue Watching bug again.
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]);
// 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) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = rate;
}, [volume, muted, rate, plan]);
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;
if (!video) return;
for (let i = 0; i < video.textTracks.length; i += 1) {
video.textTracks[i]!.mode = subtitle ? 'showing' : 'disabled';
}
}, [subtitle, plan]);
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.
//
// `position` must NOT be a dependency, however much it reads like activity. It advances ~4x a second from
// onTimeUpdate, so naming it here re-ran this effect — and so cleared the pending timer — long before 2.5s
// had passed. The chrome could then only hide while playback was stopped, which is to say never. Real
// activity is `wake()`, which sets `chrome` back to true and restarts the timer through the dep below.
useEffect(() => {
if (!chrome || !playing) return;
const timer = setTimeout(() => setChrome(false), CHROME_HIDE_MS);
return () => clearTimeout(timer);
}, [chrome, playing]);
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);
/**
* Wake on real movement only.
*
* A bare `pointermove` handler is too credulous to sit in front of a two-hour film: a mouse resting on a
* desk still emits the odd one-pixel event, a bumped table emits a few, and a browser will synthesise a
* zero-delta move of its own when the cursor style changes — which this player does every time it hides the
* cursor. Any one of those read as "the user is here" and put the scrubber back for two and a half seconds,
* minutes apart, with nobody having touched anything.
*
* The anchor only moves when a wake actually happens, so a slow deliberate drift still accumulates past the
* threshold and wakes; it is jitter around a fixed point that never does.
*/
const onPointerMove = (ev: React.PointerEvent) => {
const last = pointerRef.current;
if (last && Math.abs(ev.clientX - last.x) + Math.abs(ev.clientY - last.y) < WAKE_THRESHOLD_PX) return;
pointerRef.current = { x: ev.clientX, y: ev.clientY };
wake();
};
// 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
// the source genuinely changes — which in progressive mode is every scrub.
const source = plan && mode !== 'hls-mse' ? streamUrl(plan.url, token) : null;
const close = closeHref(pathname, params, PLAY_PARAM);
const title = item ? episodeLabel(item) : '';
const idle = playing && !chrome;
return (
<div
ref={shellRef}
tabIndex={-1}
autoFocus
onKeyDown={onKeyDown}
onPointerMove={onPointerMove}
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">
{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="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>
<p className="max-w-md text-xs text-white/60">{error}</p>
</div>
) : plan ? (
<video
key={mode === 'hls-mse' ? (plan.hlsUrl ?? plan.url) : plan.url}
ref={videoRef}
src={source ?? undefined}
className="h-full w-full"
autoPlay
playsInline
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onTimeUpdate={onTimeUpdate}
onProgress={onTimeUpdate}
onPlay={() => {
setPlaying(true);
sendReport('playing');
}}
onPause={() => {
setPlaying(false);
setChrome(true);
sendReport('progress', true);
}}
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" />
</div>
)}
</div>
{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}
/>
</div>
)}
</div>
);
};