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.
698 lines
21 KiB
Svelte
698 lines
21 KiB
Svelte
<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>
|