Vellum/web/src/lib/components/VideoPlayer.svelte
mithral 785d3ed5ee Waves 4-6: audio depth, ffprobe facts, and direct-play video
Direct play only. The server is a headless GPU-less Xeon: nothing is ever
transcoded or remuxed. A file either plays in the client or it does not.

Measured, not assumed (ffmpeg-generated files with known codecs, played through
Vellum's own stream endpoint in Chrome 155 / Linux):
  mp4  h264+aac   plays        mkv h264+aac  PLAYS   <- container is fine
  mov  h264+aac   plays        webm vp9/opus plays
  mp4  av1+opus   plays        webm vp8/vorbis plays
  mp4  hevc+aac   AUDIO ONLY, videoWidth stays 0, no error
  mkv  hevc+flac  AUDIO ONLY
  avi  mpeg4+mp3  fails outright
All seven audio formats play. Two conclusions drive the design: codec matters
and container barely does (mkv+h264 plays, mp4+HEVC does not), and canPlayType
is unreliable in both directions — it says "no" for mov+h264 (which plays) and
"maybe" for mkv+hevc (whose video never decodes).

Therefore playability is a property of the CLIENT, not the file. The server
never declares a file playable; it reports codec facts and each client decides.
That is what makes adding mpv/libvlc/MDK later a matter of registering an engine
rather than re-architecting.

Wave 4 - audio depth
- New MusicPlayer.svelte: real queue, shuffle that walks a fixed permutation
  (so "previous" means something and nothing repeats early), repeat off/all/one,
  per-track rows, album art, next-track preload.
- AudiobookPlayer: MediaSession (OS media keys + lock screen), playback speed
  that SURVIVES a chapter change and persists, sleep timer with countdown that a
  manual pause cancels, keyboard shortcuts, next-chapter preload.
- Shared mediaSession.ts written by the coordinator before dispatch so both
  players consume one implementation instead of inventing two.

Wave 5 - ffprobe facts
- internal/probe (ffprobe wrapper, optional dependency), migration 0010 adding
  vcodec/acodec/width/height/probed_at to media_files, scanner hook, API fields.
- Probing runs only for NEW or CHANGED files and BEFORE the write transaction
  opens, so it can never hold SQLite's write lock and a rescan never becomes a
  full-library ffprobe sweep. Proven: 797ms probing scan, then 1ms rescan with
  probed_at byte-identical.
- ffprobe skips attached_pic so an mp3 with cover art reports no video codec.
- Fixed a real hole: .avi is in the video registry but had no MIME entry, so it
  was served as application/octet-stream and could never play.

Wave 6 - the video player
- VideoPlayer.svelte with resume, progress + keepalive final flush, MediaSession,
  keyboard shortcuts, multi-file picker, and codec facts shown.
- The honest path is the feature: an HEVC file makes Chrome play audio over a
  blank frame with no error, which is the worst possible outcome. The player
  detects it and says so, naming the codec and pointing at clients that can.

Coordinator fix to Wave 6: the agent's detection hung off `loadedmetadata`, but
an explicit `type` the browser cannot satisfy makes it reject the source during
selection, so NEITHER loadedmetadata NOR error fires — and the player showed a
working-looking 0:00 with a Play button, exactly the silently-broken state the
component exists to prevent. Added a load watchdog: if the element says nothing
within 2.5s, the source was rejected and the honest notice is shown. Verified
per file afterwards (h264/mkv play at 640 wide; hevc and mpeg4 show their
notices).

Verified end to end, not just compiled: 15 packages pass (including the new
internal/probe), gofmt/vet clean, svelte-check 0/0, and every claim above checked
against a running server. Note the demo's own mp3s are 100 KB of random bytes and
cannot decode; the audio players were proven against generated real audio instead.
2026-10-11 14:14:32 -04:00

698 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type MediaFile } from '../api'
import * as playback from '../playback'
import {
setNowPlaying,
setHandlers,
setPosition,
setPlaybackState,
clear as clearMediaSession,
} from '../mediaSession'
let { itemId }: { itemId: number } = $props()
// Seek step for the keyboard shortcuts and the OS seek actions, in seconds.
// 10s is the video convention (a few seconds of a scene), not the 15s of an
// audiobook chapter.
const SEEK_STEP = 10
// How close to the end counts as "finished", so a video that is not watched
// to the last frame still drops off "Continue watching".
const FINISH_WINDOW_MS = 10_000
// How long to let a freshly loaded element publish its dimensions before we
// 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.
const DECODE_GRACE_MS = 1200
// How long to wait for the element to say ANYTHING after load() before we
// conclude it rejected the source outright. Needed because an explicit `type`
// attribute the browser cannot satisfy makes it refuse the resource during
// selection: neither `loadedmetadata` NOR `error` ever fires, so a player that
// 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
let video = $state<HTMLVideoElement>()
let item = $state<Item | null>(null)
let files = $state<MediaFile[]>([])
let index = $state(0)
let loading = $state(true)
let error = $state<string | null>(null)
let playing = $state(false)
let muted = $state(false)
let currentTime = $state(0)
let duration = $state(0)
// ── 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
// cleared the moment a real picture appears.
let notice = $state('')
let undecodable = $state(false)
let videoDecoded = $state(false)
let saveTimer: number | undefined
let verifyTimer: number | undefined
let loadWatchdog: number | undefined
let resumeAt = 0
let pendingPlay = false
let finalFlushed = false
const file = $derived(files[index] ?? null)
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
const srcType = $derived(file ? playback.sourceType(file) : '')
// A <source> child only feeds the element's resource-selection algorithm when
// it is first attached; swapping the src afterwards needs an explicit load().
// This is what lets the picker switch between a multi-file item's files.
$effect(() => {
src
const el = video
if (!el) return
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()
loadWatchdog = window.setTimeout(() => {
loadWatchdog = undefined
const v = video
if (!v) return
if (playback.hasDecodedVideo(v)) return
// readyState < HAVE_METADATA means it never even parsed the container.
if (v.readyState < 1) showUndecodable(file)
}, LOAD_GRACE_MS)
})
function clearWatchdog() {
if (loadWatchdog !== undefined) {
clearTimeout(loadWatchdog)
loadWatchdog = undefined
}
}
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
// PUT-only, so sendBeacon is not an option.
window.addEventListener('pagehide', finalFlush)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeydown)
try {
const detail = await api.item(itemId)
item = detail.item
files = detail.files ?? []
if (files.length === 0) {
error = 'This item has no video file.'
return
}
// Resume on the file that was playing, at the saved offset.
if (detail.progress) {
const i = files.findIndex((f) => f.id === detail.progress!.file_id)
if (i >= 0) {
index = i
resumeAt = detail.progress.position / 1000
}
}
publishNowPlaying()
registerHandlers()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this video'
} finally {
loading = false
}
})
onDestroy(() => {
window.removeEventListener('pagehide', finalFlush)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeydown)
if (saveTimer) clearTimeout(saveTimer)
if (verifyTimer !== undefined) clearTimeout(verifyTimer)
clearWatchdog()
video?.pause()
clearMediaSession()
finalFlush()
})
function onVisibilityChange() {
if (document.visibilityState === 'hidden') finalFlush()
else finalFlushed = false
}
// ── MediaSession ─────────────────────────────────────────────────────
// The OS media keys, lock screen and browser media popup read from here; the
// helper guards every call so an unsupported browser never breaks playback.
function publishNowPlaying() {
setNowPlaying({
title: item?.title ?? 'Video',
artwork: api.coverUrl(itemId),
duration,
})
}
function registerHandlers() {
setHandlers({
play: () => {
if (video) void video.play().catch(() => {})
},
pause: () => video?.pause(),
seekTo: (d) => {
if (video && d?.seekTime != null) {
video.currentTime = d.seekTime
currentTime = d.seekTime
}
},
seekBackward: (d) => seekBy(-(d?.seekOffset ?? SEEK_STEP)),
seekForward: (d) => seekBy(d?.seekOffset ?? SEEK_STEP),
})
}
// ── Honest path ──────────────────────────────────────────────────────
// `canPlayType` is a hint and is wrong in both directions (it says "no" for
// mov+h264, which plays, and "maybe" for mkv+hevc, whose video never
// decodes). The only reliable answer is the loaded element's own dimensions,
// so the player always confirms by actually loading the file.
function resetDecodeState() {
if (verifyTimer !== undefined) {
clearTimeout(verifyTimer)
verifyTimer = undefined
}
notice = ''
undecodable = false
videoDecoded = false
}
function honestNotice(f: MediaFile | null): string {
const note = f ? playback.playbackNote(f) : ''
if (note) return note
return 'This browser cannot decode the video track in this file, so you would only hear audio over a blank picture. Vellum does not transcode.'
}
function showUndecodable(f: MediaFile | null, fallback = '') {
notice = honestNotice(f) || fallback
undecodable = true
videoDecoded = false
}
// Called after metadata loads. If the file carries a video codec and this
// client cannot decode it, the element plays AUDIO while videoWidth stays 0 —
// exactly what HEVC does in Chrome. A file with no probed codec gets the
// benefit of the doubt: it is only condemned once the element itself confirms
// 0x0 or errors, never on missing metadata.
function checkDecoded(f: MediaFile | null) {
const el = video
if (!el || !f) return
if (playback.hasDecodedVideo(el)) {
markDecoded()
return
}
// No picture yet: give a slow start a moment before declaring failure.
if (verifyTimer !== undefined) return
verifyTimer = window.setTimeout(() => {
verifyTimer = undefined
const v = video
if (!v) return
if (playback.hasDecodedVideo(v)) markDecoded()
else showUndecodable(f)
}, DECODE_GRACE_MS)
}
function markDecoded() {
if (verifyTimer !== undefined) {
clearTimeout(verifyTimer)
verifyTimer = undefined
}
videoDecoded = true
undecodable = false
notice = ''
}
function onResize() {
// The element fires `resize` when videoWidth/videoHeight first become
// non-zero, which is the moment a picture actually appears.
if (video && playback.hasDecodedVideo(video)) markDecoded()
}
function onError() {
const el = video
clearWatchdog()
const code = el?.error?.code
// 4 is MEDIA_ERR_SRC_NOT_SUPPORTED — the common one for avi/mpeg4 and for
// an mkv whose tracks are not understood. It means the file will never play
// here, so say so rather than showing a spinner forever.
const suffix = code === 4 ? ' (the file format or codec is not supported here)' : ''
showUndecodable(file, (el?.error?.message || 'This video failed to load.') + suffix)
}
// ── Playback ─────────────────────────────────────────────────────────
function onLoadedMetadata() {
const el = video
if (!el) return
clearWatchdog()
duration = el.duration || 0
resetDecodeState()
checkDecoded(file)
if (resumeAt > 0 && isFinite(el.duration) && resumeAt < el.duration) {
el.currentTime = resumeAt
resumeAt = 0
}
// The new source is loaded: autoplay a file we just switched to.
if (pendingPlay) {
pendingPlay = false
void el.play().catch(() => {})
}
publishNowPlaying()
setPosition(el.currentTime, duration)
}
function onTimeUpdate() {
const el = video
if (!el) return
currentTime = el.currentTime
setPosition(currentTime, el.duration || duration)
scheduleSave()
}
function toggle() {
if (!video) return
if (video.paused) void video.play().catch(() => {})
else video.pause()
}
function onPlay() {
playing = true
setPlaybackState('playing')
}
function onPause() {
playing = false
setPlaybackState('paused')
}
function onEnded() {
flush(true)
}
function seekBy(delta: number) {
const el = video
if (!el) return
const limit = isFinite(el.duration) && el.duration > 0 ? el.duration : Infinity
const next = Math.min(Math.max(el.currentTime + delta, 0), limit)
el.currentTime = next
currentTime = next
}
function toggleMute() {
const el = video
if (!el) return
el.muted = !el.muted
muted = el.muted
}
function toggleFullscreen() {
const el = video
if (!el) return
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {})
else void el.requestFullscreen().catch(() => {})
}
// ── File picker ──────────────────────────────────────────────────────
// A video library groups by file, so an item can carry more than one. The
// picker switches the element's source between them.
function selectFile(i: number) {
if (i < 0 || i >= files.length || i === index) return
flush()
index = i
resumeAt = 0
resetDecodeState()
// Autoplay once Svelte has swapped the src and it has loaded; playing from a
// microtask can race the DOM update and replay the old file.
pendingPlay = true
}
// ── Keyboard ─────────────────────────────────────────────────────────
// Space = play/pause, ←/→ = seek ∓10s, F = fullscreen, M = mute. Ignored
// while typing so the shortcuts never eat a search or form field.
function onKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return
}
switch (event.key) {
case ' ':
case 'Spacebar':
event.preventDefault()
toggle()
break
case 'ArrowLeft':
event.preventDefault()
seekBy(-SEEK_STEP)
break
case 'ArrowRight':
event.preventDefault()
seekBy(SEEK_STEP)
break
case 'f':
case 'F':
event.preventDefault()
toggleFullscreen()
break
case 'm':
case 'M':
event.preventDefault()
toggleMute()
break
}
}
// ── Progress ─────────────────────────────────────────────────────────
// Progress is milliseconds and is keyed by file, so a multi-file item
// resumes exactly where it stopped.
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => flush(), 2000)
}
function progressBody(forceFinished = false) {
const el = video
const f = file
if (!el || !f) return null
const pos = Math.floor(el.currentTime * 1000)
const dur = Math.floor((el.duration || 0) * 1000)
const nearEnd = dur > 0 && dur - pos <= FINISH_WINDOW_MS
return {
file_id: f.id,
position: pos,
duration: dur,
percent: dur > 0 ? pos / dur : 0,
finished: forceFinished || nearEnd,
}
}
function flush(finished = false) {
const body = progressBody(finished)
if (!body) return
void api.putProgress(itemId, body).catch(() => {})
}
// Last-chance save on teardown. Uses fetch with keepalive so the PUT survives
// the document being unloaded, and latches so the overlapping pagehide /
// visibilitychange / onDestroy events only fire one request. Progress is
// PUT-only, so sendBeacon cannot be used.
function finalFlush() {
if (finalFlushed) return
const body = progressBody(false)
if (!body) return
finalFlushed = true
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = undefined
}
void fetch(`/api/v1/items/${itemId}/progress`, {
method: 'PUT',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {})
}
function seek(event: Event) {
const value = Number((event.currentTarget as HTMLInputElement).value)
if (video) video.currentTime = value
currentTime = value
}
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) seconds = 0
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const mm = String(m).padStart(2, '0')
const ss = String(s).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
}
function codecSummary(f: MediaFile): string {
const parts: string[] = []
if (f.vcodec) parts.push(f.vcodec)
if (f.acodec) parts.push(f.acodec)
if (f.width && f.height) parts.push(`${f.width}×${f.height}`)
return parts.join(' · ')
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if loading}
<div class="center"><div class="spinner"></div></div>
{:else if files.length === 0}
<div class="empty">
<h3>{item?.title ?? 'Video'}</h3>
<p>This item has no video file to play.</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
</div>
{:else}
<div class="player card" class:undecodable>
<div class="head">
<div class="meta">
<strong>{item?.title}</strong>
<span class="muted">
{file?.path.split('/').pop() ?? ''}
{#if files.length > 1}· {index + 1} of {files.length}{/if}
</span>
{#if file && codecSummary(file)}
<span class="badge mono">{codecSummary(file)}</span>
{/if}
</div>
<a class="btn btn-ghost dl" href={api.fileUrl(itemId)} download>Download</a>
</div>
{#if notice}
<div class="notice" role="alert">
<strong>Video cannot play here</strong>
<p>{notice}</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
</div>
{/if}
<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. -->
<video
bind:this={video}
class="video"
controls
playsinline
preload="metadata"
onloadedmetadata={onLoadedMetadata}
ontimeupdate={onTimeUpdate}
onplay={onPlay}
onpause={onPause}
onended={onEnded}
onerror={onError}
onresize={onResize}
>
<source src={src} type={srcType || undefined} />
</video>
</div>
{#if videoDecoded}
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
{/if}
<input
class="seek"
type="range"
min="0"
max={duration || 0}
step="1"
value={currentTime}
oninput={seek}
aria-label="Seek"
/>
<div class="times mono">
<span>{fmt(currentTime)}</span>
<span class="muted">{fmt(duration)}</span>
</div>
<div class="controls">
<button class="btn btn-primary play" onclick={toggle}>{playing ? 'Pause' : 'Play'}</button>
<button class="btn btn-ghost" onclick={toggleMute} aria-pressed={muted} title="Mute (M)">
{muted ? 'Unmute' : 'Mute'}
</button>
<button class="btn btn-ghost" onclick={toggleFullscreen} title="Fullscreen (F)">Fullscreen</button>
</div>
<p class="hint muted">Space play/pause · ←/→ seek {SEEK_STEP}s · F fullscreen · M mute</p>
{#if files.length > 1}
<ol class="files">
{#each files as f, i (f.id)}
<li>
<button class="file" class:current={i === index} onclick={() => selectFile(i)}>
<span class="mono">{String(i + 1).padStart(2, '0')}</span>
<span class="name">{f.path.split('/').pop()}</span>
<span class="muted mono">{codecSummary(f)}</span>
</button>
</li>
{/each}
</ol>
{/if}
</div>
{/if}
<style>
.center {
display: grid;
place-items: center;
padding: var(--space-6);
}
.player {
max-width: 960px;
margin: 0 auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.head {
display: flex;
align-items: center;
gap: var(--space-4);
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
flex: 1;
}
.meta strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta .muted {
font-size: var(--fs-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.dl {
flex: none;
}
.notice {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--danger) 10%, transparent);
font-size: var(--fs-md);
}
.notice p {
margin: 0;
color: var(--muted);
}
.stage {
background: #000;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.video {
display: block;
width: 100%;
max-height: calc(100vh - 52px - 220px);
background: #000;
}
/* The file loaded but only audio decodes: keep the audio reachable, but make
the black frame read as a known limitation rather than a working picture. */
.undecodable .stage {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
}
.ok-note {
margin: 0;
font-size: var(--fs-sm);
color: var(--success);
}
.seek {
width: 100%;
accent-color: var(--accent);
}
.times {
display: flex;
justify-content: space-between;
font-size: var(--fs-sm);
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
}
.play {
min-width: 96px;
justify-content: center;
}
.hint {
margin: 0;
font-size: var(--fs-xs);
text-align: center;
}
.files {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
max-height: 260px;
overflow-y: auto;
border-top: 1px solid var(--border);
}
.file {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: 7px var(--space-2);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
}
.file:hover {
background: var(--surface-2);
color: var(--text);
}
.file.current {
color: var(--accent);
}
.file .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>