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.
147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
// Direct-play decisions.
|
|
//
|
|
// Vellum does not transcode — the server has no spare CPU for it — so a file
|
|
// either plays in the client or it does not. Which one it is depends on the
|
|
// CLIENT, not the file: Chrome on Linux cannot decode HEVC, Safari can, and the
|
|
// native Jellyfin apps handle far more than any browser. So nothing here is a
|
|
// server-side verdict; it is a per-client guess, and the player always confirms
|
|
// it by actually loading the file.
|
|
//
|
|
// The mappings below were measured, not guessed. Files generated with known
|
|
// codecs and played through the server's own stream endpoint in Chrome 155 on
|
|
// Linux gave this:
|
|
//
|
|
// mp4 h264 + aac plays mkv h264 + aac PLAYS (container is fine!)
|
|
// webm vp9 + opus plays mov h264 + aac plays
|
|
// mp4 av1 + opus plays webm vp8 + vorbis plays
|
|
// mp4 hevc + aac AUDIO ONLY — videoWidth stays 0
|
|
// mkv hevc + flac AUDIO ONLY
|
|
// avi mpeg4 + mp3 fails outright
|
|
//
|
|
// Two things that follow, and that the UI must respect:
|
|
//
|
|
// 1. Container matters far less than codec. mkv plays fine when the video
|
|
// track is h264; mp4 fails when the track is HEVC. Judging a file by its
|
|
// extension is wrong.
|
|
// 2. `canPlayType` is a hint, not an answer. It said "no" for mov+h264 (which
|
|
// plays) and "maybe" for mkv+hevc (whose video never decodes). The only
|
|
// reliable test is to load the file and check `videoWidth`.
|
|
|
|
import type { MediaFile } from './api'
|
|
|
|
/** Container extension → MIME type. Mirrors internal/stream's table. */
|
|
const CONTAINER_MIME: Record<string, string> = {
|
|
mp4: 'video/mp4',
|
|
m4v: 'video/mp4',
|
|
mov: 'video/quicktime',
|
|
mkv: 'video/x-matroska',
|
|
webm: 'video/webm',
|
|
avi: 'video/x-msvideo',
|
|
mp3: 'audio/mpeg',
|
|
m4a: 'audio/mp4',
|
|
m4b: 'audio/mp4',
|
|
aac: 'audio/aac',
|
|
flac: 'audio/flac',
|
|
opus: 'audio/ogg',
|
|
ogg: 'audio/ogg',
|
|
wav: 'audio/wav',
|
|
}
|
|
|
|
/**
|
|
* ffprobe codec name → an RFC 6381 codec parameter good enough for
|
|
* `canPlayType`. The full parameter string (profile, level, tier) is not
|
|
* recoverable from a bare codec name, so these are the shortest forms browsers
|
|
* accept.
|
|
*/
|
|
const VCODEC: Record<string, string> = {
|
|
h264: 'avc1.42E01E',
|
|
avc1: 'avc1.42E01E',
|
|
hevc: 'hvc1.1.6.L93.B0',
|
|
h265: 'hvc1.1.6.L93.B0',
|
|
vp8: 'vp8',
|
|
vp9: 'vp09.00.10.08',
|
|
av1: 'av01.0.04M.08',
|
|
mpeg4: 'mp4v.20.9',
|
|
mpeg2video: 'mp2v',
|
|
vc1: 'vc-1',
|
|
theora: 'theora',
|
|
}
|
|
|
|
const ACODEC: Record<string, string> = {
|
|
aac: 'mp4a.40.2',
|
|
mp3: 'mp3',
|
|
opus: 'opus',
|
|
vorbis: 'vorbis',
|
|
flac: 'flac',
|
|
ac3: 'ac-3',
|
|
eac3: 'ec-3',
|
|
dts: 'dts',
|
|
pcm_s16le: 'pcm',
|
|
}
|
|
|
|
export type Verdict = 'probably' | 'maybe' | 'no'
|
|
|
|
/** The MIME type for a file's container, or '' if it is unknown. */
|
|
export function mimeFor(file: MediaFile): string {
|
|
return CONTAINER_MIME[(file.container || '').toLowerCase()] ?? ''
|
|
}
|
|
|
|
/**
|
|
* Build the `type` string for a media element, e.g.
|
|
* `video/mp4; codecs="avc1.42E01E, mp4a.40.2"`. Returns '' when the container
|
|
* is unknown, in which case the caller should let the element sniff the file.
|
|
*/
|
|
export function sourceType(file: MediaFile): string {
|
|
const mime = mimeFor(file)
|
|
if (!mime) return ''
|
|
const codecs: string[] = []
|
|
const v = VCODEC[(file.vcodec || '').toLowerCase()]
|
|
const a = ACODEC[(file.acodec || '').toLowerCase()]
|
|
if (v) codecs.push(v)
|
|
if (a) codecs.push(a)
|
|
return codecs.length ? `${mime}; codecs="${codecs.join(', ')}"` : mime
|
|
}
|
|
|
|
/**
|
|
* Ask this browser whether it can play the file. A hint only — see the note at
|
|
* the top of this file about how wrong `canPlayType` is in both directions.
|
|
*/
|
|
export function canDirectPlay(file: MediaFile): Verdict {
|
|
if (typeof document === 'undefined') return 'maybe'
|
|
const type = sourceType(file)
|
|
if (!type) return 'maybe'
|
|
const el = document.createElement('video')
|
|
const verdict = el.canPlayType(type)
|
|
if (verdict === 'probably') return 'probably'
|
|
if (verdict === 'maybe') return 'maybe'
|
|
return 'no'
|
|
}
|
|
|
|
/**
|
|
* A short human explanation for a file that is not expected to play, or '' when
|
|
* there is nothing worth saying. The wording avoids blaming the user's machine
|
|
* and points at the actual cause.
|
|
*/
|
|
export function playbackNote(file: MediaFile): string {
|
|
if (canDirectPlay(file) !== 'no') return ''
|
|
const codec = (file.vcodec || '').toLowerCase()
|
|
if (codec === 'hevc' || codec === 'h265') {
|
|
return 'This file uses HEVC video, which this browser cannot decode. Safari and the native apps can.'
|
|
}
|
|
if (codec === 'mpeg4' || codec === 'mpeg2video' || codec === 'vc1') {
|
|
return `This file uses ${codec} video, which browsers no longer support.`
|
|
}
|
|
return 'This browser cannot play this file directly, and Vellum does not transcode.'
|
|
}
|
|
|
|
/**
|
|
* Whether a loaded media element actually decoded a video track.
|
|
*
|
|
* This is the reliable test, and the reason the player never trusts
|
|
* `canPlayType`: an element whose metadata loaded but whose video track is
|
|
* undecodable reports a duration and plays AUDIO while `videoWidth` stays 0.
|
|
* That is exactly what HEVC does in Chrome.
|
|
*/
|
|
export function hasDecodedVideo(el: HTMLVideoElement): boolean {
|
|
return el.videoWidth > 0 && el.videoHeight > 0
|
|
}
|