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:
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user