diff --git a/web/package.json b/web/package.json index 2b6b236..4637601 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "epubjs": "^0.3.93", + "hls.js": "^1.7.3", "pdfjs-dist": "^6.4.299" }, "devDependencies": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 78ec82c..982f17c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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: {} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1d0928c..db210dd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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}`, diff --git a/web/src/lib/components/VideoPlayer.svelte b/web/src/lib/components/VideoPlayer.svelte index 59db388..74d744d 100644 --- a/web/src/lib/components/VideoPlayer.svelte +++ b/web/src/lib/components/VideoPlayer.svelte @@ -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(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 @@
- +