Play HLS in the web player with hls.js

The server could transcode and serve HLS; nothing could play it. Chrome has no
native HLS support, so without this the feature existed only for curl.

hls.js is the only real option for HLS in Chrome, and it is MIT. It is loaded
with a dynamic import, so the ~185 KB gzip is paid only when a transcode
actually happens - a direct play, which is the common case, fetches none of it.

Safari and iOS play HLS natively, so those get the playlist URL directly and
never load the library at all.

Three failure paths all fall back to the direct file URL rather than leaving a
dead player: hls.js not supported, the dynamic import failing, and a fatal
playback error. Transcoding is an optimisation and must never be able to break
playback - a file that would have direct-played has to keep working when the
encoder or the library is unavailable.

The instance is destroyed on source change and on unmount, so seeking between
files does not leave encoders or MediaSource objects behind.

Verified: pnpm check 0/0, the production build succeeds, and hls.js is emitted
as its own lazy chunk rather than folded into the main bundle.

Not verified in a browser - there is no automated browser test in this repo, so
the actual playback of a transcoded stream is unproven end to end. The server
side of that path is verified separately against real hardware.
This commit is contained in:
mithral 2026-10-12 12:07:36 -04:00
parent fd377a7446
commit 5281a6690d
4 changed files with 103 additions and 8 deletions

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"epubjs": "^0.3.93",
"hls.js": "^1.7.3",
"pdfjs-dist": "^6.4.299"
},
"devDependencies": {

8
web/pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
epubjs:
specifier: ^0.3.93
version: 0.3.93
hls.js:
specifier: ^1.7.3
version: 1.7.3
pdfjs-dist:
specifier: ^6.4.299
version: 6.4.299
@ -568,6 +571,9 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hls.js@1.7.3:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
@ -1139,6 +1145,8 @@ snapshots:
fsevents@2.3.3:
optional: true
hls.js@1.7.3: {}
immediate@3.0.6: {}
inherits@2.0.4: {}

View file

@ -214,6 +214,8 @@ export const api = {
fileUrlById: (id: number, fileId: number) => `/api/v1/items/${id}/files/${fileId}`,
transcodePlaylist: (id: number) => `/api/v1/items/${id}/transcode/playlist.m3u8`,
subtitles: (id: number) => request<{ subtitles: Subtitle[] }>('GET', `/api/v1/items/${id}/subtitles`),
subtitleUrl: (id: number, trackId: number) => `/api/v1/items/${id}/subtitles/${trackId}`,

View file

@ -3,6 +3,7 @@
import { api, ApiError, type Item, type MediaFile, type Subtitle } from '../api'
import * as playback from '../playback'
import { clientEngines, probeFile } from '../capabilities'
import type Hls from 'hls.js'
import {
setNowPlaying,
setHandlers,
@ -72,8 +73,32 @@
let pendingPlay = false
let finalFlushed = false
// Whether this browser plays an HLS playlist through a plain src (Safari and
// iOS can; Chrome cannot). When it cannot, hls.js drives the element through
// MediaSource instead, and the element must carry no src at all.
const nativeHls =
typeof document !== 'undefined' &&
document.createElement('video').canPlayType('application/vnd.apple.mpegurl') !== ''
// Set when the server resolved this item to "transcode": nothing this client
// declared can direct-play the file, so the server packages it as HLS. The
// resolve answer describes the item's FIRST file and the server transcodes
// that same file, so the stream is used only while that file is selected.
let transcodeUrl = $state<string | null>(null)
const useHls = $derived(transcodeUrl !== null && index === 0)
const file = $derived(files[index] ?? null)
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
// Native HLS plays the playlist through src; for hls.js src stays undefined
// and the HLS effect below feeds the element instead.
const src = $derived(
useHls
? nativeHls
? transcodeUrl ?? undefined
: undefined
: file
? api.fileUrlById(itemId, file.id)
: undefined,
)
const srcType = $derived(file ? playback.sourceType(file) : '')
// The server resolves an ITEM using its first file's codec facts, so its
// advisory applies only while that first file is selected. Once the element
@ -95,10 +120,16 @@
// load(). The watchdog covers the case where the element never responds.
$effect(() => {
src
useHls
const el = video
if (!el) return
resetDecodeState()
clearWatchdog()
// With hls.js there is no src to load — it feeds the element through
// MediaSource — so the element watchdog does not apply; the HLS effect owns
// that path and reports its own failures. Native HLS still arrives through
// src and keeps the watchdog.
if (useHls && !nativeHls) return
loadWatchdog = window.setTimeout(() => {
loadWatchdog = undefined
const v = video
@ -109,6 +140,49 @@
}, LOAD_GRACE_MS)
})
// ── HLS transcoding ──────────────────────────────────────────────────
// The server packages the item as HLS when nothing this client declared can
// direct-play it. Safari plays the playlist natively through src; everywhere
// else hls.js feeds the element through MediaSource. The library is imported
// lazily so its bytes are only paid for when a transcode is actually needed,
// and every failure — hls.js missing, a load error, a fatal stream error —
// clears the stream so the element falls back to the direct file URL. A
// transcode must never leave a dead player. The cleanup destroys the instance
// on a source change and on unmount.
$effect(() => {
const el = video
const url = transcodeUrl
if (!el || !url || nativeHls || index !== 0) return
let hls: Hls | null = null
let cancelled = false
void import('hls.js')
.then(({ default: Hls }) => {
if (cancelled || !el) return
if (!Hls.isSupported()) {
transcodeUrl = null
return
}
hls = new Hls()
hls.on(Hls.Events.ERROR, (_event, data) => {
// hls.js recovers from non-fatal errors itself; a fatal one is worth
// abandoning HLS for, so it drops to direct play.
if (data.fatal) transcodeUrl = null
})
hls.attachMedia(el)
hls.loadSource(url)
})
.catch(() => {
transcodeUrl = null
})
return () => {
cancelled = true
hls?.destroy()
hls = null
}
})
function clearWatchdog() {
if (loadWatchdog !== undefined) {
clearTimeout(loadWatchdog)
@ -266,7 +340,11 @@
try {
const engines = await clientEngines()
const res = await api.resolvePlayback(itemId, engines)
if (!res.playable && res.message) serverNote = res.message
if (res.playable && res.engine === 'transcode') {
transcodeUrl = api.transcodePlaylist(itemId)
} else if (!res.playable && res.message) {
serverNote = res.message
}
} catch {
// Endpoint absent (older server) or unreachable: the element watchdog
// still covers the honest path.
@ -593,11 +671,13 @@
<div class="stage">
<!-- svelte-ignore a11y_media_has_caption -->
<!-- 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. -->
<!-- Direct play, or the HLS playlist when the server transcoded (the
latter only on browsers with native HLS; hls.js feeds the element
with no src). 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"
@ -617,7 +697,11 @@
</div>
{#if videoDecoded}
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
<p class="ok-note" aria-live="polite">
{useHls
? 'Playing via a server transcode — this browser cannot direct-play the file.'
: 'Playing video directly — no transcoding.'}
</p>
{/if}
{#if qualityNote && !undecodable}