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:
parent
6201535dd7
commit
08b894c411
2 changed files with 94 additions and 5 deletions
|
|
@ -214,6 +214,10 @@ export const api = {
|
||||||
|
|
||||||
fileUrlById: (id: number, fileId: number) => `/api/v1/items/${id}/files/${fileId}`,
|
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`),
|
progress: (id: number) => request<{ progress: Progress | null }>('GET', `/api/v1/items/${id}/progress`),
|
||||||
|
|
||||||
putProgress: (
|
putProgress: (
|
||||||
|
|
@ -316,6 +320,21 @@ export interface ItemDetail {
|
||||||
progress?: Progress
|
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 ────────────────────────────────────────────────────
|
// ── playback engines ────────────────────────────────────────────────────
|
||||||
// Playability is a property of the CLIENT, so a client declares the engines it
|
// 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
|
// has and the server matches them against the file's codec facts. A browser
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
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 * as playback from '../playback'
|
||||||
import { clientEngines, probeFile } from '../capabilities'
|
import { clientEngines, probeFile } from '../capabilities'
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,6 +42,12 @@
|
||||||
let currentTime = $state(0)
|
let currentTime = $state(0)
|
||||||
let duration = $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 ────────────────────────────────────────────────
|
// ── Honest-path state ────────────────────────────────────────────────
|
||||||
// `notice` is non-empty only when this client genuinely cannot decode the
|
// `notice` is non-empty only when this client genuinely cannot decode the
|
||||||
// video track; `undecodable` drives the "audio only" presentation. Both are
|
// 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 () => {
|
onMount(async () => {
|
||||||
// Persist the final position even if the page is torn down: keepalive lets a
|
// 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
|
// 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,
|
// 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.
|
// so this never blocks the player and a failure is never shown as an error.
|
||||||
void refinePlayback()
|
void refinePlayback()
|
||||||
|
void loadSubtitles()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'could not open this video'
|
error = err instanceof ApiError ? err.message : 'could not open this video'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -550,10 +593,11 @@
|
||||||
|
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<!-- Direct play only: Vellum serves the file untouched and has no subtitle
|
<!-- Direct play only: Vellum serves the file untouched. The subtitle
|
||||||
tracks to offer, so there is nothing to caption. src is on the element
|
<track> is added imperatively (see the subtitles $effect), so the
|
||||||
(not a <source>) so the browser actually loads the file and reports
|
a11y caption check cannot see it; the ignore stays. src is on the
|
||||||
videoWidth / error; see videoAttrs above. -->
|
element (not a <source>) so the browser actually loads the file and
|
||||||
|
reports videoWidth / error; see videoAttrs above. -->
|
||||||
<video
|
<video
|
||||||
bind:this={video}
|
bind:this={video}
|
||||||
class="video"
|
class="video"
|
||||||
|
|
@ -604,6 +648,22 @@
|
||||||
<button class="btn btn-ghost" onclick={toggleFullscreen} title="Fullscreen (F)">Fullscreen</button>
|
<button class="btn btn-ghost" onclick={toggleFullscreen} title="Fullscreen (F)">Fullscreen</button>
|
||||||
</div>
|
</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>
|
<p class="hint muted">Space play/pause · ←/→ seek {SEEK_STEP}s · F fullscreen · M mute</p>
|
||||||
|
|
||||||
{#if files.length > 1}
|
{#if files.length > 1}
|
||||||
|
|
@ -729,6 +789,16 @@
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
text-align: center;
|
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 {
|
.files {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: var(--space-2) 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue