Files
hamlab/web/src/Player.tsx
T

419 lines
24 KiB
TypeScript
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.
// A video in the Files viewer, YouTube's way: Auto, which reads the connection and picks the
// quality (hls.js), any quality by hand, or Original — the file as it is, for a browser that
// decodes it. The qualities are made on the fly, from wherever the viewer seeks to, on the device
// the Transcode device setting names (Settings), which may be on another machine than the file.
// A 360° video is drawn as the view from inside it (vr.ts): drag, pinch or scroll, and on a
// phone the motion button. The same player as Ham Share's.
import { useCallback, useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { api, nodeAPI, type PlayOpen, type PlayStatus } from './api'
import { canMotion, Sphere } from './vr'
type Choice = 'auto' | 'original' | number // a number is a width: the rung's name
// The bandwidth hls.js measured last time, so the next video starts near the right quality
// rather than at the bottom.
const BW_KEY = 'hamlab.play.bw'
const savedBW = () => { try { const n = Number(localStorage.getItem(BW_KEY)); return n > 0 ? n : 0 } catch { return 0 } }
const saveBW = (n: number) => { try { if (n > 0) localStorage.setItem(BW_KEY, String(Math.round(n))) } catch { /* private mode */ } }
// hls.js reads the connection from how long each piece takes. Here a piece can be slow to
// start because the machine is still making it (a few seconds after a seek or a change of
// quality, at 8K), which says nothing about the connection; counted, it drags Auto down to the
// bottom, and every drop starts the making over again. So: the wait before the first byte is
// not counted against the connection, and hls.js doesn't give up on a piece before its first
// byte comes. Once bytes flow, its usual rules apply.
type AbrLike = { new(hls: Hls): object }
const Base = Hls.DefaultConfig.abrController as unknown as AbrLike
type Stats = { loading: { start: number; first: number } }
type Proto = { initEstimator: () => { sampleTTFB: (ms: number) => void }; onFragBuffered: (ev: unknown, d: unknown) => void }
const proto = Base.prototype as unknown as Proto
class PatientAbr extends Base {
constructor(hls: Hls) {
super(hls)
const self = this as unknown as { _abandonRulesCheck: (l?: unknown) => void; fragCurrent: { stats: Stats } | null }
const orig = self._abandonRulesCheck
self._abandonRulesCheck = (l?: unknown) => {
const f = self.fragCurrent
if (f && !f.stats.loading.first) return
orig(l)
}
}
initEstimator() {
const e = proto.initEstimator.call(this)
const sample = e.sampleTTFB.bind(e)
e.sampleTTFB = (ms: number) => sample(Math.min(ms, 300))
return e
}
onFragBuffered(ev: unknown, d: { frag: { stats: Stats }; part?: { stats: Stats & { loaded: number } } | null }) {
const st = d.part?.stats.loaded ? d.part.stats : d.frag.stats
const start = st.loading.start
if (st.loading.first > start) st.loading.start = st.loading.first
try { proto.onFragBuffered.call(this, ev, d) } finally { st.loading.start = start }
}
}
// Whether the browser lets a page set the volume level (iOS doesn't: only mute).
const canLevel = (() => { try { const a = document.createElement('audio'); a.volume = 0.5; return a.volume === 0.5 } catch { return false } })()
const mbps = (b: number) => `${b >= 10e6 ? Math.round(b / 1e6) : (b / 1e6).toFixed(1)} Mb/s`
const clock = (sec: number) => { const t = Math.max(0, Math.floor(sec)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` }
const size = (w?: number, h?: number, vr?: boolean) => {
if (!w || !h) return ''
if (vr) return `${w >= 7680 ? '8K' : w >= 5760 ? '5.7K' : w >= 3840 ? '4K' : w >= 3072 ? '3K' : `${w}×${h}`} 360°`
const s = Math.min(w, h)
return s >= 2160 ? '4K' : s >= 1440 ? '1440p' : s >= 1080 ? '1080p' : s >= 720 ? '720p' : `${w}×${h}`
}
const I = ({ d, size: sz = 20, fill }: { d: string; size?: number; fill?: boolean }) => (
<svg width={sz} height={sz} viewBox="0 0 24 24" fill={fill ? 'currentColor' : 'none'} stroke={fill ? 'none' : 'currentColor'} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
)
const PLAY = 'M7 4.5v15l12.5-7.5z', PAUSE = 'M8 5v14M16 5v14', CHECK = 'M5 12.5l4.5 4.5L19 7.5', TUNE = 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4'
const FULL = 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', SMALL = 'M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5', PHONE = 'M8 3h8a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 9a9 9 0 0 0 0 6M21 9a9 9 0 0 1 0 6'
// The speaker: waves by the level, a cross when muted.
const Speaker = ({ off, low }: { off?: boolean; low?: boolean }) => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" />
{off ? <path d="M16 9.5l5 5M21 9.5l-5 5" /> : <path d={low ? 'M15.5 9.5a3.5 3.5 0 0 1 0 5' : 'M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10'} />}
</svg>
)
// Five seconds back or on: a circling arrow with the 5 inside.
const Skip = ({ back }: { back?: boolean }) => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={back ? 'M12 5a7 7 0 1 1-7 7M12 5l2.5-2.5M12 5l2.5 2.5' : 'M12 5a7 7 0 1 0 7 7M12 5L9.5 2.5M12 5l-2.5 2.5'} />
<text x="12" y="15" textAnchor="middle" fontSize="7.5" fontWeight="700" fill="currentColor" stroke="none" fontFamily="system-ui, sans-serif">5</text>
</svg>
)
export default function Player({ path, sudo }: { path: string; sudo: boolean; name: string }) {
const [open, setOpen] = useState<PlayOpen | null>(null)
const [err, setErr] = useState('')
const [note, setNote] = useState('')
const [choice, setChoice] = useState<Choice>('auto')
const [showing, setShowing] = useState(0) // the width playing now
const [status, setStatus] = useState<PlayStatus | null>(null)
const [paused, setPaused] = useState(true)
const [waiting, setWaiting] = useState(true)
const [time, setTime] = useState(0)
const [scrub, setScrub] = useState<number | null>(null)
const [buffered, setBuffered] = useState(0)
const [menu, setMenu] = useState(false)
const [chrome, setChrome] = useState(true)
const [motion, setMotion] = useState(false)
const [full, setFull] = useState(false) // the browser's full screen, or (an iPhone) the window
const box = useRef<HTMLDivElement>(null)
const video = useRef<HTMLVideoElement>(null)
const canvas = useRef<HTMLCanvasElement>(null)
const hls = useRef<Hls | null>(null)
const sphere = useRef<Sphere | null>(null)
const hideTimer = useRef<number>()
const resume = useRef<{ at: number; play: boolean }>({ at: 0, play: true })
const openRef = useRef(open)
openRef.current = open
const menuRef = useRef(menu)
menuRef.current = menu
// Open the play; let it go when this player does, and when the page goes, by a beacon.
useEffect(() => {
let live = true
setOpen(null); setErr(''); setNote(''); setChoice('auto'); resume.current = { at: 0, play: true }
api.play(path, sudo).then((o) => {
if (!live) { api.playStop(o.node, o.play.id).catch(() => {}); return }
setOpen(o)
if (o.note) setNote(o.note)
}).catch((e) => { if (live) setErr(e instanceof Error ? e.message : 'failed') })
return () => { live = false; const o = openRef.current; if (o) api.playStop(o.node, o.play.id).catch(() => { /* gone already */ }) }
}, [path, sudo])
useEffect(() => {
const h = () => { const o = openRef.current; if (o) navigator.sendBeacon(nodeAPI(o.node, `/api/files/play/${o.play.id}/close`)) }
window.addEventListener('pagehide', h)
return () => window.removeEventListener('pagehide', h)
}, [])
const info = open?.play.info
const vr = !!info?.video?.spherical
useEffect(() => {
if (!vr || !canvas.current || !video.current) return
try { sphere.current = new Sphere(canvas.current, video.current) } catch (e) { setErr((e as Error).message) }
return () => { sphere.current?.destroy(); sphere.current = null }
}, [vr, open])
// The source: hls.js for Auto and the chosen qualities, the file itself for Original.
useEffect(() => {
const v = video.current
if (!open || !v) return
const { at, play } = resume.current
const start = () => { if (at) v.currentTime = at; if (play) v.play().catch(() => setPaused(true)) }
if (choice === 'original') {
v.src = api.fileReadURL(path, sudo)
v.addEventListener('loadedmetadata', start, { once: true })
setShowing(info?.video?.width ?? 0)
const bad = () => {
resume.current = { at: v.currentTime || at, play: true }
setNote('This browser can’t play the file as it is. Back on Auto.')
setChoice('auto')
}
v.addEventListener('error', bad, { once: true })
return () => { v.removeEventListener('error', bad); v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
}
const master = nodeAPI(open.node, open.master)
if (Hls.isSupported()) {
const h = new Hls({
abrController: PatientAbr as unknown as typeof Hls.DefaultConfig.abrController,
abrEwmaDefaultEstimate: savedBW() || 5_000_000,
startPosition: at || -1,
maxBufferLength: 30,
maxMaxBufferLength: 60,
backBufferLength: 60,
// A piece being made after a seek can take a while; the machine waits up to 45 s.
fragLoadPolicy: { default: { maxTimeToFirstByteMs: 50_000, maxLoadTimeMs: 120_000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 4, retryDelayMs: 1000, maxRetryDelayMs: 8000 } } },
})
hls.current = h
h.on(Hls.Events.MANIFEST_PARSED, () => {
if (typeof choice === 'number') {
const i = h.levels.findIndex((l) => l.width === choice)
if (i >= 0) { h.startLevel = i; h.currentLevel = i }
}
if (play) v.play().catch(() => setPaused(true))
})
h.on(Hls.Events.LEVEL_SWITCHED, (_, d) => setShowing(h.levels[d.level]?.width ?? 0))
h.on(Hls.Events.FRAG_BUFFERED, () => saveBW(h.bandwidthEstimate))
let recovered = 0
h.on(Hls.Events.ERROR, (_, d) => {
if (!d.fatal) return
if (d.type === Hls.ErrorTypes.MEDIA_ERROR && recovered++ < 3) { h.recoverMediaError(); return }
if (d.type === Hls.ErrorTypes.NETWORK_ERROR && recovered++ < 3) { h.startLoad(v.currentTime); return }
setErr(`The video stopped loading (${d.details}).`)
})
h.loadSource(master)
h.attachMedia(v)
return () => { saveBW(h.bandwidthEstimate); h.destroy(); hls.current = null }
}
// Safari without Media Source (an older iPhone): its own HLS, Auto only.
v.src = master
v.addEventListener('loadedmetadata', start, { once: true })
return () => { v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
}, [open, choice === 'original' ? 'o' : 'h']) // eslint-disable-line react-hooks/exhaustive-deps
// A hand-picked quality, or back to Auto, while hls.js is already playing.
useEffect(() => {
const h = hls.current
if (!h || choice === 'original' || !h.levels.length) return
if (choice === 'auto') { h.currentLevel = -1; return }
const i = h.levels.findIndex((l) => l.width === choice)
if (i >= 0) h.currentLevel = i
}, [choice])
// What the machine is doing, while the menu is open.
useEffect(() => {
if (!menu || !open) return
let live = true
const tick = () => api.playStatus(open.node, open.play.id).then((s) => { if (live) setStatus(s) }).catch(() => {})
tick()
const t = window.setInterval(tick, 2000)
return () => { live = false; window.clearInterval(t) }
}, [menu, open])
// Controls fade while it plays and nobody touches anything.
const poke = useCallback(() => {
setChrome(true)
window.clearTimeout(hideTimer.current)
hideTimer.current = window.setTimeout(() => { if (!video.current?.paused && !menuRef.current) setChrome(false) }, 3000)
}, [])
useEffect(() => () => window.clearTimeout(hideTimer.current), [])
useEffect(() => { if (!menu) poke() }, [menu, poke])
const toggle = useCallback(() => {
const v = video.current
if (!v) return
if (v.paused) void v.play().catch(() => {})
else v.pause()
poke()
}, [poke])
// The seek bar: while it is dragged the ball follows the finger (the input event); the seek
// itself is on release (the browser's own change event, which a click, a drag and the arrow
// keys all end with). The ball is put where the seek went at once, so it waits there while
// the piece is made rather than going back to where the film was.
const seekBar = useRef<HTMLInputElement>(null)
useEffect(() => {
const el = seekBar.current
if (!el) return
const commit = () => {
const x = Number(el.value), v = video.current
if (v && Math.abs(v.currentTime - x) > 0.05) v.currentTime = x
setTime(x)
setScrub(null)
}
el.addEventListener('change', commit)
return () => el.removeEventListener('change', commit)
})
// Volume: a level and mute, remembered for the next video. iOS lets a page mute but not set
// the level (it reads back as 1), so there the slider isn't shown.
const [vol, setVol] = useState(() => { try { const v = JSON.parse(localStorage.getItem('hamlab.play.volume') ?? ''); return { level: Math.min(1, Math.max(0, Number(v.level) || 0)), muted: !!v.muted } } catch { return { level: 1, muted: false } } })
useEffect(() => {
const v = video.current
if (v) { v.volume = vol.level; v.muted = vol.muted || vol.level === 0 }
try { localStorage.setItem('hamlab.play.volume', JSON.stringify(vol)) } catch { /* private mode */ }
}, [vol, open])
const volBy = useCallback((d: number) => setVol((x) => ({ level: Math.round(Math.min(1, Math.max(0, x.level + d)) * 20) / 20, muted: false })), [])
const muteToggle = useCallback(() => setVol((x) => (x.muted || x.level === 0 ? { level: x.level || 0.5, muted: false } : { ...x, muted: true })), [])
const seekBy = useCallback((d: number) => {
const v = video.current
if (!v) return
const to = Math.max(0, Math.min(info?.duration ?? v.duration, v.currentTime + d))
v.currentTime = to
setTime(to) // the ball goes there at once, made yet or not
poke()
}, [info, poke])
// Full screen: the browser's where it has one for an element, else (an iPhone) the window.
const fullscreen = useCallback(() => {
const el = box.current as (HTMLDivElement & { webkitRequestFullscreen?: () => void }) | null
if (!el) return
if (document.fullscreenElement) { void document.exitFullscreen(); return }
if (el.requestFullscreen) { void el.requestFullscreen().catch(() => setFull((f) => !f)); return }
if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); return }
setFull((f) => !f)
}, [])
useEffect(() => {
const f = () => setFull(!!document.fullscreenElement)
document.addEventListener('fullscreenchange', f)
return () => document.removeEventListener('fullscreenchange', f)
}, [])
// Keys work while the player has the focus (a click on it gives it).
const onKey = (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'k') { e.preventDefault(); toggle() }
else if (e.key === 'ArrowLeft' || e.key === 'j') { e.preventDefault(); seekBy(e.key === 'j' ? -10 : -5) }
else if (e.key === 'ArrowRight' || e.key === 'l') { e.preventDefault(); seekBy(e.key === 'l' ? 10 : 5) }
else if (e.key === 'f') fullscreen()
else if (e.key === 'm') muteToggle()
else if (e.key === 'ArrowUp') { e.preventDefault(); volBy(0.1) }
else if (e.key === 'ArrowDown') { e.preventDefault(); volBy(-0.1) }
else if (e.key === 'Escape') { if (menu) setMenu(false); else if (full && !document.fullscreenElement) setFull(false) }
}
// Looking around a 360° video: a drag turns the view, a pinch zooms, a tap shows or hides the
// controls.
const pointers = useRef(new Map<number, { x: number; y: number }>())
const moved = useRef(0)
const pinch = useRef(0)
const onDown = (e: React.PointerEvent) => {
(e.target as Element).setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
moved.current = 0
if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()]; pinch.current = Math.hypot(a.x - b.x, a.y - b.y) }
}
const onMove = (e: React.PointerEvent) => {
const p = pointers.current.get(e.pointerId)
if (!p) return
const dx = e.clientX - p.x, dy = e.clientY - p.y
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
moved.current += Math.abs(dx) + Math.abs(dy)
if (pointers.current.size === 2) {
const [a, b] = [...pointers.current.values()]
const d = Math.hypot(a.x - b.x, a.y - b.y)
if (pinch.current) sphere.current?.zoom(pinch.current / d)
pinch.current = d
} else sphere.current?.drag(dx, dy)
}
const onUp = (e: React.PointerEvent) => {
pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) pinch.current = 0
if (pointers.current.size === 0 && moved.current < 6) {
if (e.pointerType === 'mouse') toggle()
else if (chrome && !menu) setChrome(false)
else poke()
setMenu(false)
}
}
const v = video.current
const dur = info?.duration ?? 0
const shown = scrub ?? time
const rungs = open?.play.rungs ?? []
const label = (w: number) => rungs.find((r) => r.width === w)?.label ?? (w && w === info?.video?.width ? 'Original' : '')
const choiceLabel = choice === 'auto' ? `Auto${showing ? ` · ${label(showing)}` : ''}` : choice === 'original' ? 'Original' : label(choice)
const tooBig = vr && sphere.current && (info?.video?.width ?? 0) > sphere.current.maxTexture
const fileRate = info?.bit_rate || (info?.size && dur ? (info.size * 8) / dur : 0)
const keep = () => { if (v) resume.current = { at: v.currentTime, play: !v.paused } }
return (
<div ref={box} tabIndex={0} onKeyDown={onKey} className={`vp${chrome ? '' : ' bare'}${full ? ' max' : ''}`}
onPointerMove={(e) => { if (e.pointerType === 'mouse') poke() }}
onWheel={vr ? (e) => { if (!(e.target as Element).closest('.vp-menu')) sphere.current?.zoom(e.deltaY > 0 ? 1.08 : 1 / 1.08) } : undefined}>
<video ref={video} className={vr ? 'vp-v vp-hidden' : 'vp-v'} playsInline preload="auto"
onPlay={() => { setPaused(false); poke() }} onPause={() => { setPaused(true); setChrome(true) }}
onWaiting={() => setWaiting(true)} onPlaying={() => setWaiting(false)} onCanPlay={() => setWaiting(false)} onSeeking={() => setWaiting(true)} onSeeked={() => setWaiting(false)}
onTimeUpdate={(e) => {
const el = e.currentTarget
setTime(el.currentTime)
resume.current = { at: el.currentTime, play: !el.paused }
for (let i = 0; i < el.buffered.length; i++) if (el.buffered.start(i) <= el.currentTime + 0.5) setBuffered(el.buffered.end(i))
}}
onClick={vr ? undefined : () => { if (matchMedia('(pointer: coarse)').matches) { if (chrome) setChrome(false); else poke() } else toggle() }}
onDoubleClick={vr ? undefined : fullscreen} />
{vr && <canvas ref={canvas} className="vp-v vp-sphere" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onDoubleClick={fullscreen} />}
{(waiting || !open) && !err && <div className="vp-spin" aria-label="Loading" />}
{err && <div className="vp-err"><p>{err}</p></div>}
{open && paused && !waiting && !err && <button className="vp-big" onClick={toggle} aria-label="Play"><I d={PLAY} size={36} fill /></button>}
{note && <div className="vp-note" role="status" onClick={() => setNote('')}>{note}</div>}
{vr && open && chrome && !menu && time < 8 && !err && <div className="vp-hint">Drag to look around · scroll or pinch to zoom</div>}
<div className="vp-bottom">
<span className="vp-time above">{clock(shown)} / {clock(dur)}</span>
<div className="vp-seek" style={{ ['--p' as string]: dur ? `${(shown / dur) * 100}%` : '0%', ['--b' as string]: dur ? `${(buffered / dur) * 100}%` : '0%' }}>
<input ref={seekBar} type="range" min={0} max={dur || 1} step={0.1} value={shown} aria-label="Seek"
onChange={(e) => { setScrub(Number(e.target.value)); poke() }} />
</div>
<div className="vp-row">
<button className="vp-icon" onClick={toggle} aria-label={paused ? 'Play' : 'Pause'}>{paused ? <I d={PLAY} fill /> : <I d={PAUSE} />}</button>
<button className="vp-icon" onClick={() => seekBy(-5)} aria-label="Back 5 seconds" title="Back 5 seconds"><Skip back /></button>
<button className="vp-icon" onClick={() => seekBy(5)} aria-label="On 5 seconds" title="On 5 seconds"><Skip /></button>
<span className="vp-vol">
<button className="vp-icon" onClick={muteToggle} aria-label={vol.muted || vol.level === 0 ? 'Unmute' : 'Mute'} title={vol.muted || vol.level === 0 ? 'Unmute (m)' : 'Mute (m)'}><Speaker off={vol.muted || vol.level === 0} low={vol.level < 0.5} /></button>
{canLevel && <input type="range" min={0} max={1} step={0.05} value={vol.muted ? 0 : vol.level} aria-label="Volume"
style={{ ['--v' as string]: `${(vol.muted ? 0 : vol.level) * 100}%` }}
onChange={(e) => { const level = Number(e.target.value); setVol({ level, muted: level === 0 }); poke() }} />}
</span>
<span className="vp-time inrow">{clock(shown)} / {clock(dur)}</span>
<span className="spacer" />
{vr && canMotion() && (
<button className={`vp-icon${motion ? ' on' : ''}`} aria-label="Look around by moving the phone" aria-pressed={motion}
onClick={async () => { const s = sphere.current; if (s) setMotion(await s.setGyro(!s.gyroOn)) }}><I d={PHONE} /></button>
)}
<button className="vp-q" onClick={() => { setMenu((m) => !m); poke() }} aria-label="Quality" aria-expanded={menu}><I d={TUNE} size={16} /><span>{choiceLabel}</span></button>
<button className="vp-icon" onClick={fullscreen} aria-label={full ? 'Exit full screen' : 'Full screen'}><I d={full ? SMALL : FULL} /></button>
</div>
</div>
{menu && open && (
<div className="vp-menu" role="menu">
<div className="vp-menu-h">Quality</div>
<button role="menuitemradio" aria-checked={choice === 'auto'} onClick={() => { setChoice('auto'); setMenu(false) }}>
<span className="ck">{choice === 'auto' && <I d={CHECK} size={16} />}</span><span>Auto</span><small>{choice === 'auto' && showing ? label(showing) : 'fits the connection'}</small>
</button>
<button role="menuitemradio" aria-checked={choice === 'original'} disabled={!!tooBig} onClick={() => { keep(); setChoice('original'); setMenu(false) }}>
<span className="ck">{choice === 'original' && <I d={CHECK} size={16} />}</span><span>Original</span>
<small>{tooBig ? 'too big for this device' : [size(info?.video?.width, info?.video?.height, vr), fileRate ? mbps(fileRate) : ''].filter(Boolean).join(' · ')}</small>
</button>
{rungs.map((r) => (
<button key={r.name} role="menuitemradio" aria-checked={choice === r.width} onClick={() => { keep(); setChoice(r.width); setMenu(false) }}>
<span className="ck">{choice === r.width && <I d={CHECK} size={16} />}</span><span>{r.label}</span><small>{mbps(r.bitrate)}</small>
</button>
))}
<div className="vp-menu-f">
Made on <b>{open.host}</b> · {status?.engine || open.engine}
{status?.engine && <> · {status.paused ? 'far enough ahead, waiting' : `from ${clock(status.from)}`}</>}
</div>
</div>
)}
</div>
)
}