Files
jellyfin/web/PlayerControls.tsx
T
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

405 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};