Video player: load the file, don't let the browser skip it
Follow-up to 785d3ed. The committed version put the codec `type` on a <source>
child. That is the obvious thing to do and it is wrong: an explicit type lets
Chrome SKIP the resource during selection whenever canPlayType disagrees with
reality, and canPlayType is wrong in both directions.
The cost was measurable. mov+h264 has canPlayType('video/quicktime') === "no",
so the browser refused to try it — a file that PLAYS would have been reported as
unplayable. Verified now at 640x360, readyState 4.
Setting src on the element instead makes the browser actually load the file and
report videoWidth / error honestly, which is what playback.ts's measured notes
describe:
mov+h264 -> loads, videoWidth 640 -> plays
mp4+hevc -> loads, videoWidth 0 -> detected by the decode check
avi+mpeg4 -> error code 4 -> detected by the error path
The `type` is kept only as an ignored hint, and the load watchdog still covers
the case where the element says nothing at all. All three detection paths are
now reachable, which is the point: for HEVC, Chrome plays audio over a blank
frame with no error, and that must never be mistaken for working playback.
Process note, against the coordinator: I edited this file while its agent still
owned it, and the edit introduced the mov+h264 regression above. The agent
reverted it and kept the watchdog, which was the useful half. Do not edit a file
an agent owns mid-task; hand the finding over instead.
This commit is contained in:
parent
785d3ed5ee
commit
915f390156
2 changed files with 81 additions and 17 deletions
|
|
@ -121,6 +121,24 @@ export const api = {
|
||||||
scanLibrary: (id: number) =>
|
scanLibrary: (id: number) =>
|
||||||
request<{ queued: number; job_id: number }>('POST', `/api/v1/libraries/${id}/scan`),
|
request<{ queued: number; job_id: number }>('POST', `/api/v1/libraries/${id}/scan`),
|
||||||
|
|
||||||
|
// ── playback engines ──
|
||||||
|
engines: () => request<{ engines: EngineSupport[] }>('GET', '/api/v1/playback/engines'),
|
||||||
|
|
||||||
|
resolvePlayback: (itemId: number, engines: string[]) =>
|
||||||
|
request<PlaybackResolution>('POST', '/api/v1/playback/resolve', {
|
||||||
|
item_id: itemId,
|
||||||
|
engines,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── missing files ──
|
||||||
|
missing: (libraryId?: number) => {
|
||||||
|
const q = libraryId ? `?library_id=${libraryId}` : ''
|
||||||
|
return request<{ items: MissingEntry[]; total: number }>('GET', `/api/v1/missing${q}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
purgeMissing: (libraryId: number) =>
|
||||||
|
request<{ purged: number }>('POST', `/api/v1/libraries/${libraryId}/purge`),
|
||||||
|
|
||||||
scanStatus: (id: number) =>
|
scanStatus: (id: number) =>
|
||||||
request<{
|
request<{
|
||||||
state: 'queued' | 'running' | 'done' | 'failed' | 'idle'
|
state: 'queued' | 'running' | 'done' | 'failed' | 'idle'
|
||||||
|
|
@ -298,6 +316,47 @@ export interface ItemDetail {
|
||||||
progress?: Progress
|
progress?: Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
// offers "html5"; a native client offers "mpv", "vlc", "mdk" as well.
|
||||||
|
export interface EngineSupport {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: 'browser' | 'native'
|
||||||
|
/** Lowercase container extensions it can open, e.g. "mkv". */
|
||||||
|
containers: string[]
|
||||||
|
/** Video codec names it can decode, e.g. "h264", "hevc". */
|
||||||
|
vcodecs: string[]
|
||||||
|
/** Audio codec names it can decode. */
|
||||||
|
acodecs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackResolution {
|
||||||
|
playable: boolean
|
||||||
|
/** The engine that should play it, or null when nothing can. */
|
||||||
|
engine: string | null
|
||||||
|
/** Machine-readable cause when not playable, e.g. "vcodec:hevc". */
|
||||||
|
reason: string
|
||||||
|
/** Human text for the UI. Empty when playable. */
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── missing files ───────────────────────────────────────────────────────
|
||||||
|
// A scan never deletes: a file that vanishes is marked missing so its progress
|
||||||
|
// survives a temporarily unmounted drive. Nothing is removed until an admin
|
||||||
|
// explicitly purges, which is what these two calls are for.
|
||||||
|
export interface MissingEntry {
|
||||||
|
item_id: number
|
||||||
|
file_id: number
|
||||||
|
library_id: number
|
||||||
|
kind: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
/** When the file was first seen missing, RFC3339. */
|
||||||
|
missing_since: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
library_id: number
|
library_id: number
|
||||||
scanned: number
|
scanned: number
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,10 @@
|
||||||
// conclude the video track is undecodable. Long enough for a slow start, short
|
// conclude the video track is undecodable. Long enough for a slow start, short
|
||||||
// enough that a black frame is never mistaken for a working picture.
|
// enough that a black frame is never mistaken for a working picture.
|
||||||
const DECODE_GRACE_MS = 1200
|
const DECODE_GRACE_MS = 1200
|
||||||
// How long to wait for the element to say ANYTHING after load() before we
|
// Belt-and-braces: if the element says nothing at all after a source change
|
||||||
// conclude it rejected the source outright. Needed because an explicit `type`
|
// (neither loadedmetadata nor error), it rejected the resource during
|
||||||
// attribute the browser cannot satisfy makes it refuse the resource during
|
// selection. Only a timer will notice, and showing the honest notice is the
|
||||||
// selection: neither `loadedmetadata` NOR `error` ever fires, so a player that
|
// whole point of this component.
|
||||||
// only reacts to those two events shows 0:00 and a Play button forever — the
|
|
||||||
// silently-broken state this component exists to avoid.
|
|
||||||
const LOAD_GRACE_MS = 2500
|
const LOAD_GRACE_MS = 2500
|
||||||
|
|
||||||
let video = $state<HTMLVideoElement>()
|
let video = $state<HTMLVideoElement>()
|
||||||
|
|
@ -61,25 +59,30 @@
|
||||||
const file = $derived(files[index] ?? null)
|
const file = $derived(files[index] ?? null)
|
||||||
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
|
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
|
||||||
const srcType = $derived(file ? playback.sourceType(file) : '')
|
const srcType = $derived(file ? playback.sourceType(file) : '')
|
||||||
|
// `type` belongs on a <source>, but putting it there lets the browser SKIP a
|
||||||
|
// file whose canPlayType answer is wrong — it would refuse to try mov+h264
|
||||||
|
// (which plays) and would never even parse HEVC, so `videoWidth` stays 0 and
|
||||||
|
// no `error` fires; the honest path below would never see it. Setting src on
|
||||||
|
// the element makes the browser load the file and report `videoWidth`/`error`
|
||||||
|
// for real, which is the measured behaviour playback.ts describes. The type is
|
||||||
|
// kept only as an ignored hint.
|
||||||
|
const videoAttrs = $derived(srcType ? { type: srcType } : {})
|
||||||
|
|
||||||
// A <source> child only feeds the element's resource-selection algorithm when
|
// Setting the src attribute restarts the element's load algorithm, so the
|
||||||
// it is first attached; swapping the src afterwards needs an explicit load().
|
// picker can switch between a multi-file item's files without an explicit
|
||||||
// This is what lets the picker switch between a multi-file item's files.
|
// load(). The watchdog covers the case where the element never responds.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
src
|
src
|
||||||
const el = video
|
const el = video
|
||||||
if (!el) return
|
if (!el) return
|
||||||
resetDecodeState()
|
resetDecodeState()
|
||||||
el.load()
|
|
||||||
// Arm the watchdog: if the element neither loads metadata nor errors, the
|
|
||||||
// source was rejected during selection and only this timer will notice.
|
|
||||||
clearWatchdog()
|
clearWatchdog()
|
||||||
loadWatchdog = window.setTimeout(() => {
|
loadWatchdog = window.setTimeout(() => {
|
||||||
loadWatchdog = undefined
|
loadWatchdog = undefined
|
||||||
const v = video
|
const v = video
|
||||||
if (!v) return
|
if (!v) return
|
||||||
if (playback.hasDecodedVideo(v)) return
|
if (playback.hasDecodedVideo(v)) return
|
||||||
// readyState < HAVE_METADATA means it never even parsed the container.
|
// readyState < HAVE_METADATA means it never parsed the container at all.
|
||||||
if (v.readyState < 1) showUndecodable(file)
|
if (v.readyState < 1) showUndecodable(file)
|
||||||
}, LOAD_GRACE_MS)
|
}, LOAD_GRACE_MS)
|
||||||
})
|
})
|
||||||
|
|
@ -492,10 +495,14 @@
|
||||||
<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 and has no subtitle
|
||||||
tracks to offer, so there is nothing to caption. -->
|
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. -->
|
||||||
<video
|
<video
|
||||||
bind:this={video}
|
bind:this={video}
|
||||||
class="video"
|
class="video"
|
||||||
|
src={src}
|
||||||
|
{...videoAttrs}
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
|
@ -506,9 +513,7 @@
|
||||||
onended={onEnded}
|
onended={onEnded}
|
||||||
onerror={onError}
|
onerror={onError}
|
||||||
onresize={onResize}
|
onresize={onResize}
|
||||||
>
|
></video>
|
||||||
<source src={src} type={srcType || undefined} />
|
|
||||||
</video>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if videoDecoded}
|
{#if videoDecoded}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue