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}`,
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue