// 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 }) => ( ) 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 }) => ( ) // Five seconds back or on: a circling arrow with the 5 inside. const Skip = ({ back }: { back?: boolean }) => ( ) export default function Player({ path, sudo }: { path: string; sudo: boolean; name: string }) { const [open, setOpen] = useState(null) const [err, setErr] = useState('') const [note, setNote] = useState('') const [choice, setChoice] = useState('auto') const [showing, setShowing] = useState(0) // the width playing now const [status, setStatus] = useState(null) const [paused, setPaused] = useState(true) const [waiting, setWaiting] = useState(true) const [time, setTime] = useState(0) const [scrub, setScrub] = useState(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(null) const video = useRef(null) const canvas = useRef(null) const hls = useRef(null) const sphere = useRef(null) const hideTimer = useRef() 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(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()) 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 (
{ 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}>