Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
419 lines
24 KiB
TypeScript
419 lines
24 KiB
TypeScript
// 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>
|
||
)
|
||
}
|
||
|