Add a subtitle picker to the video player

The API could serve tracks but nothing asked for them. The picker lists what
the item has and attaches one <track> at a time, removing the previous element
first so switching never accumulates them.

Default is Off. Auto-selecting a track would turn subtitles on for people who
did not ask and cannot tell why, and the failure mode is worse than a missing
feature: a track that loads over a film you did not want subtitled.

Subtitles never block playback. A failed track list hides the picker, and a
track that fails to load leaves the video playing - the media element's own
state is never touched by any of this.

Not verified by a test: the web package has no test runner, only svelte-check,
so verification here is the type and a11y check (0 errors, 0 warnings) plus
the API end-to-end below.

Verified end-to-end against a running server: a video with "Show.en.srt" and
"Show.pt-BR.srt" beside it produced two sidecar rows with the right languages,
the list route returned both, and the track route served real WebVTT - header,
Content-Type text/vtt, and the SRT commas converted to dots.
This commit is contained in:
mithral 2026-10-12 11:45:14 -04:00
parent 6201535dd7
commit 08b894c411
2 changed files with 94 additions and 5 deletions

View file

@ -214,6 +214,10 @@ export const api = {
fileUrlById: (id: number, fileId: number) => `/api/v1/items/${id}/files/${fileId}`,
subtitles: (id: number) => request<{ subtitles: Subtitle[] }>('GET', `/api/v1/items/${id}/subtitles`),
subtitleUrl: (id: number, trackId: number) => `/api/v1/items/${id}/subtitles/${trackId}`,
progress: (id: number) => request<{ progress: Progress | null }>('GET', `/api/v1/items/${id}/progress`),
putProgress: (
@ -316,6 +320,21 @@ export interface ItemDetail {
progress?: Progress
}
// Subtitle carries the facts about one subtitle track. The server renders every
// format as WebVTT on the way out, so a client only needs the id to build the
// track URL; Path and StreamIndex stay server-side.
export interface Subtitle {
id: number
/** Where the track came from: "embedded", "sidecar" or "downloaded". */
source: string
/** Language tag, or "" when the file did not carry one. */
language: string
/** Human label for the picker, e.g. "English (SDH)". */
label: string
/** Original format, e.g. "srt", "ass", "vtt". */
format: string
}
// ── playback engines ────────────────────────────────────────────────────
// Playability is a property of the CLIENT, so a client declares the engines it
// has and the server matches them against the file's codec facts. A browser

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type MediaFile } from '../api'
import { api, ApiError, type Item, type MediaFile, type Subtitle } from '../api'
import * as playback from '../playback'
import { clientEngines, probeFile } from '../capabilities'
import {
@ -42,6 +42,12 @@
let currentTime = $state(0)
let duration = $state(0)
// Subtitle tracks for the item, and the one the viewer picked. Empty list =
// no picker (the item has none, or the list could not be loaded); null =
// Off, which is always the default — a track is never auto-selected.
let subtitles = $state<Subtitle[]>([])
let subtitleId = $state<number | null>(null)
// ── Honest-path state ────────────────────────────────────────────────
// `notice` is non-empty only when this client genuinely cannot decode the
// video track; `undecodable` drives the "audio only" presentation. Both are
@ -110,6 +116,42 @@
}
}
// ── Subtitles ────────────────────────────────────────────────────────
// One <track> is mirrored onto the element from `subtitleId`. The old one is
// removed first, so switching never accumulates tracks. Best-effort by
// design: a track that fails to load is the browser's problem and leaves
// playback running, and an empty list simply shows no picker.
$effect(() => {
const el = video
const id = subtitleId
el?.querySelectorAll('track').forEach((t) => t.remove())
if (!el || id === null) return
const track = subtitles.find((s) => s.id === id)
if (!track) return
const node = document.createElement('track')
node.kind = 'subtitles'
node.src = api.subtitleUrl(itemId, track.id)
node.label = track.label
node.srclang = track.language || 'und'
node.default = true
el.appendChild(node)
})
// The list is fetched when the player opens. A failure is never surfaced:
// without a list there is no picker, but the video is unaffected.
async function loadSubtitles() {
try {
subtitles = (await api.subtitles(itemId)).subtitles ?? []
} catch {
subtitles = []
}
}
function selectSubtitle(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value
subtitleId = value === 'off' ? null : Number(value)
}
onMount(async () => {
// Persist the final position even if the page is torn down: keepalive lets a
// PUT outlive the document, which sendBeacon (POST-only) cannot. Progress is
@ -139,6 +181,7 @@
// file. Best-effort: the element watchdog below is still the authority,
// so this never blocks the player and a failure is never shown as an error.
void refinePlayback()
void loadSubtitles()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this video'
} finally {
@ -550,10 +593,11 @@
<div class="stage">
<!-- svelte-ignore a11y_media_has_caption -->
<!-- Direct play only: Vellum serves the file untouched and has no subtitle
tracks to offer, so there is nothing to caption. src is on the element
(not a <source>) so the browser actually loads the file and reports
videoWidth / error; see videoAttrs above. -->
<!-- Direct play only: Vellum serves the file untouched. The subtitle
<track> is added imperatively (see the subtitles $effect), so the
a11y caption check cannot see it; the ignore stays. src is on the
element (not a <source>) so the browser actually loads the file and
reports videoWidth / error; see videoAttrs above. -->
<video
bind:this={video}
class="video"
@ -604,6 +648,22 @@
<button class="btn btn-ghost" onclick={toggleFullscreen} title="Fullscreen (F)">Fullscreen</button>
</div>
{#if subtitles.length > 0}
<div class="subs">
<label class="muted" for="subtitle-track">Subtitles</label>
<select
id="subtitle-track"
value={subtitleId === null ? 'off' : String(subtitleId)}
onchange={selectSubtitle}
>
<option value="off">Off</option>
{#each subtitles as s (s.id)}
<option value={String(s.id)}>{s.label || s.language || 'Subtitle'}</option>
{/each}
</select>
</div>
{/if}
<p class="hint muted">Space play/pause · ←/→ seek {SEEK_STEP}s · F fullscreen · M mute</p>
{#if files.length > 1}
@ -729,6 +789,16 @@
font-size: var(--fs-xs);
text-align: center;
}
.subs {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
font-size: var(--fs-sm);
}
.subs select {
padding: 4px var(--space-2);
}
.files {
list-style: none;
margin: var(--space-2) 0 0;