diff --git a/src/servers/sidecar/jellyfin/routes.ts b/src/servers/sidecar/jellyfin/routes.ts index 9c7fb67d..0b96c413 100644 --- a/src/servers/sidecar/jellyfin/routes.ts +++ b/src/servers/sidecar/jellyfin/routes.ts @@ -190,12 +190,24 @@ async function item(cfg: UpstreamConfig, id: string): Promise { 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 `` 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(tracks: T[]): T[] { + const counts = new Map(); + 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 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, }); } diff --git a/src/workspaces/officerdev/src/apps/Jellyfin/PlayerControls.tsx b/src/workspaces/officerdev/src/apps/Jellyfin/PlayerControls.tsx new file mode 100644 index 00000000..76427db4 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Jellyfin/PlayerControls.tsx @@ -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(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 ( +
+ + {open && ( +
+

{label}

+ {children(() => setOpen(false))} +
+ )} +
+ ); +}; + +type OptionProps = { selected: boolean; onSelect: () => void; children: React.ReactNode; hint?: string | null }; + +const Option = ({ selected, onSelect, children, hint }: OptionProps) => ( + +); + +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(null); + const [dragging, setDragging] = useState(null); + const [hover, setHover] = useState(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) => { + ev.currentTarget.setPointerCapture(ev.pointerId); + const seconds = secondsAt(ev.clientX); + setDragging(seconds); + if (liveScrub) props.onScrub(seconds); + }; + + const onPointerMove = (ev: React.PointerEvent) => { + 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) => { + 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 ( +
+
+
+ {formatClock(shown)} + +
setHover(null)} + className="group relative h-6 flex-1 cursor-pointer touch-none select-none" + > +
+
+
+
+
+ {hover !== null && duration > 0 && ( + + {formatClock(hover)} + + )} +
+ + {formatClock(duration)} +
+ +
+ + + + + {/* 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. */} +
+ + 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" + /> +
+ + + + {props.busy && switching…} + + } label="Subtitles" active={props.subtitleIndex !== null}> + {(close) => ( + <> + + {props.subtitles.length === 0 && ( +

This file has no subtitle tracks.

+ )} + {props.subtitles.map((track) => + track.url ? ( + + ) : ( +

+ {track.label} + unavailable — {track.unavailable} +

+ ), + )} + + )} +
+ + } label="Audio track"> + {(close) => ( + <> + {props.audio.map((track) => ( + + ))} + {props.audio.length <= 1 && ( +

Only one audio track.

+ )} + + )} +
+ + } label="Speed and quality"> + {(close) => ( + <> +

Speed

+ {PLAYBACK_RATES.map((rate) => ( + + ))} +

+ Quality +

+ + {props.qualityRungs.map((rung) => ( + + ))} + + )} +
+ + +
+ + {(currentSubtitle || currentQuality) && ( +

+ {[ + currentSubtitle && `subtitles: ${currentSubtitle.label}`, + currentQuality && `quality: ${currentQuality.label}`, + ] + .filter(Boolean) + .join(' · ')} +

+ )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Jellyfin/VideoPlayer.tsx b/src/workspaces/officerdev/src/apps/Jellyfin/VideoPlayer.tsx index 8bb7754a..f43de727 100644 --- a/src/workspaces/officerdev/src/apps/Jellyfin/VideoPlayer.tsx +++ b/src/workspaces/officerdev/src/apps/Jellyfin/VideoPlayer.tsx @@ -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=`. @@ -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