DR-235 phase 3. Every video renderer is native now: mpv on Linux and Windows, ExoPlayer on Android, all drawing behind the transparent webview. The HTML5 <video> path is gone, not bypassed: - Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the createAdapter factory, streamTransport, hlsRecovery, timeTracking, videoFit, the <video>/<track> markup and every element handler in VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one video adapter; webview audio gets its own adapter kind. - Rust: use_html5 dropped from player_seek_video, player_switch_audio_track and player_set_stream_quality with the Html5* strategies and ReloadStream responses; use_html5_element and VideoBackend dropped from PlayerStatus; player_play_item always loads the backend (set_current_item removed); Capabilities::webview removed; the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed. - Android: the HTML5 video state in PictureInPictureManager and ScreenWakeManager, and the bridge method feeding it. - CSP: connect-src loses http:/https: and worker-src loses blob: - both existed for hls.js; with it gone they were only an exfiltration channel and a blob worker for injected script. A test now keeps them out. mpv takes over what the <video> element did (mpv_tracks, UT-275): subtitles are the WebVTT list the play request carries, queued on sub-files and selected by position in that list, starting off; audio tracks are selected by position in the file; sid/aid are reset before each load. Without this, Linux video had no subtitle selection and a direct-play audio switch failed since mpv became its renderer. Verified: Rust 948 passing, and the same 948 cross-compiled for Windows under wine against the shipped DLL (track tests included); frontend 1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI ratchet tightened to match. Not yet seen on Windows hardware.
194 lines
8.2 KiB
TypeScript
194 lines
8.2 KiB
TypeScript
// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
|
|
//
|
|
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
|
|
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
|
|
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
|
|
// attribute and every `<track>` pointed at "[object Promise]". The whole block
|
|
// was commented out rather than fixed, which left `<video>` with no text tracks
|
|
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
|
|
// `textTracks` list and the subtitle menu silently did nothing.
|
|
//
|
|
// The rule this module enforces: URLs are resolved to plain strings *here*, off
|
|
// the render path, and only tracks that actually resolved are handed to the
|
|
// markup.
|
|
//
|
|
// The Android / ExoPlayer native path shares this module (see
|
|
// nativeSubtitleTracks / nativeSubtitleArrayIndex at the bottom): it needs the
|
|
// exact same "resolve the URLs first, keep only what resolved" list, just handed
|
|
// to Rust instead of to `<track>` elements.
|
|
//
|
|
// TRACES: UR-020 | DR-023, IR-016 | UT-143, UT-144, UT-147
|
|
|
|
import type { SubtitleTrack } from "$lib/api/bindings";
|
|
|
|
/**
|
|
* The subset of `MediaStream` (from the generated bindings) this module needs.
|
|
* Kept structural so tests do not have to build full binding objects.
|
|
*/
|
|
export interface SubtitleStreamLike {
|
|
index: number;
|
|
kind?: string | null;
|
|
language?: string | null;
|
|
displayTitle?: string | null;
|
|
isDefault?: boolean;
|
|
isForced?: boolean;
|
|
/**
|
|
* The backend's verdict on whether this track can arrive as a sidecar the app
|
|
* renders itself. `false` means only the server could have shown it, by
|
|
* burning it into the picture — which the app never asks for. Absent means no
|
|
* verdict was given, which is not the same as "no".
|
|
*/
|
|
supportsExternalDelivery?: boolean | null;
|
|
}
|
|
|
|
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
|
|
export interface RenderableSubtitleTrack {
|
|
/** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
|
|
streamIndex: number;
|
|
/** Fully resolved WebVTT URL. Always a string, never a Promise. */
|
|
url: string;
|
|
srclang: string;
|
|
label: string;
|
|
/** Server's "default" flag — shown in the menu, never auto-enabled. */
|
|
isDefault: boolean;
|
|
}
|
|
|
|
/**
|
|
* Subtitle streams of a media item that the app can actually show, in stream
|
|
* order. This is the one list behind everything: the picker, the `<track>`
|
|
* children, and the array sent to the native backend.
|
|
*
|
|
* Image-based subtitles (PGS/DVD/DVB) are filtered out here rather than at each
|
|
* consumer. They are bitmaps — a client can only display one if the server
|
|
* composites it into the video, and the app deliberately asks for no burn-in at
|
|
* all (DR-176), so such a track is one it can never draw. Leaving it in the
|
|
* picker produced a control that ticked and showed nothing.
|
|
*
|
|
* The judgement is the backend's: `supportsExternalDelivery` arrives already
|
|
* decided, because *which formats are bitmaps* is domain vocabulary and belongs
|
|
* in Rust. Only an explicit `false` drops a stream; a stream carrying no verdict
|
|
* is kept, so a source that never sets the field behaves exactly as before.
|
|
*
|
|
* Generic in the stream type so callers keep their own richer fields (the menu
|
|
* reads `codec` off the result).
|
|
*
|
|
* TRACES: UR-020 | DR-176 | UT-168
|
|
*/
|
|
export function subtitleStreamsOf<T extends SubtitleStreamLike>(
|
|
streams: readonly T[] | null | undefined,
|
|
): T[] {
|
|
if (!streams) return [];
|
|
return streams.filter((s) => s.kind === "subtitle" && s.supportsExternalDelivery !== false);
|
|
}
|
|
|
|
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
|
|
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
|
|
return stream.displayTitle || stream.language || `Track ${stream.index}`;
|
|
}
|
|
|
|
/** A src we are willing to put on a `<track>`: a non-blank plain string. */
|
|
function isRenderableUrl(url: unknown): url is string {
|
|
return typeof url === "string" && url.trim().length > 0;
|
|
}
|
|
|
|
/**
|
|
* Resolve every subtitle stream's URL and return only the tracks that can be
|
|
* rendered. `resolveUrl` failures are swallowed per track: one unavailable
|
|
* subtitle must not cost the user the others, and a dead `src` on a media
|
|
* element is exactly what made this block get disabled in the first place.
|
|
*/
|
|
export async function resolveSubtitleTracks(
|
|
streams: readonly SubtitleStreamLike[] | null | undefined,
|
|
resolveUrl: (streamIndex: number) => Promise<string>,
|
|
): Promise<RenderableSubtitleTrack[]> {
|
|
const subtitles = subtitleStreamsOf(streams);
|
|
if (subtitles.length === 0) return [];
|
|
|
|
const resolved = await Promise.all(
|
|
subtitles.map(async (stream) => {
|
|
try {
|
|
const url = await resolveUrl(stream.index);
|
|
if (!isRenderableUrl(url)) return null;
|
|
return {
|
|
streamIndex: stream.index,
|
|
url,
|
|
srclang: stream.language || "und",
|
|
label: subtitleTrackLabel(stream),
|
|
isDefault: stream.isDefault === true,
|
|
} satisfies RenderableSubtitleTrack;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
|
}
|
|
|
|
// ===== The native player =====================================================
|
|
//
|
|
// The native backend gets the list *up front*, as part of the play request:
|
|
// ExoPlayer sideloads subtitles as `MediaItem.SubtitleConfiguration`s that must
|
|
// exist before `prepare()`, and mpv queues them as external files for the load.
|
|
// There is no "add a subtitle later" — a track absent from the request simply
|
|
// does not exist as far as the player is concerned.
|
|
|
|
/**
|
|
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
|
*
|
|
* The element type is the *generated* `SubtitleTrack` binding on purpose, so
|
|
* `bun run check` fails if the Rust struct's field names ever move. In
|
|
* particular `mime_type` is snake_case and must stay that way: the very same
|
|
* bytes are re-serialized across JNI in `player/android/mod.rs`, and
|
|
* `JellyTauPlayer.load()` reads `optString("mime_type")`. Renaming it to
|
|
* `mimeType` would not error anywhere — Kotlin would just silently fall back to
|
|
* its default MIME type for every track.
|
|
*
|
|
* Jellyfin is asked for every subtitle stream as WebVTT (see
|
|
* `getSubtitleUrl(..., "vtt")`), so the MIME type is fixed rather than derived
|
|
* from the source subtitle codec.
|
|
*
|
|
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
|
*/
|
|
export function nativeSubtitleTracks(tracks: readonly RenderableSubtitleTrack[]): SubtitleTrack[] {
|
|
return tracks.map((track) => ({
|
|
index: track.streamIndex,
|
|
url: track.url,
|
|
// `srclang` carries "und" for a stream with no language, which is the right
|
|
// value for a `<track>` but is not a language the native side should claim.
|
|
language: track.srclang === "und" ? null : track.srclang,
|
|
label: track.label,
|
|
mime_type: "text/vtt",
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* The argument for `player_set_subtitle_track` on the native backend.
|
|
*
|
|
* 🔴 This is **not** the Jellyfin stream index.
|
|
* `JellyTauPlayer.setSubtitleTrack(n)` filters ExoPlayer's track groups down to
|
|
* `C.TRACK_TYPE_TEXT` and indexes that list with `n`, so `n` is the *position of
|
|
* the sideloaded subtitle configuration* — which is the position in the array
|
|
* that `nativeSubtitleTracks()` produced and `playerPlayItem` sent.
|
|
*
|
|
* The menu's own row number is not that position: the menu lists every subtitle
|
|
* *stream*, while only the streams whose URL resolved are sent. One failed URL
|
|
* and everything below it selects the wrong subtitle. So the index is looked up
|
|
* in the sent list instead of being passed down from the `{#each}`.
|
|
*
|
|
* `null` (the menu's "Off") stays `null`, which the backend turns into -1 and
|
|
* Kotlin turns into "disable text tracks". A stream that was never sent also
|
|
* maps to `null`: disabling subtitles is a truthful outcome, whereas guessing a
|
|
* position would show the user a different language than the one they clicked.
|
|
*
|
|
* TRACES: UR-020 | IR-016 | UT-147
|
|
*/
|
|
export function nativeSubtitleArrayIndex(
|
|
tracks: readonly RenderableSubtitleTrack[],
|
|
streamIndex: number | null,
|
|
): number | null {
|
|
if (streamIndex === null) return null;
|
|
const position = tracks.findIndex((t) => t.streamIndex === streamIndex);
|
|
return position === -1 ? null : position;
|
|
}
|