files: Ham Share's player — Auto quality, any by hand, or the file; 360 view; Transcode device setting
Every video plays YouTube's way: hls.js reads the connection and picks the quality, or one is picked by hand, or Original plays the file as it is. The qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead. 360 videos open in a look-around view (drag, pinch or scroll to zoom from 8 to 300 degrees, phone motion). Settings -> Transcode device picks the machine and device that makes them, for every machine's files: a machine transcoding another's file reads it through the hub by a token for that one file. Replaces the codec/size picker and the single-quality re-encoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f5f1cd79ba
commit
fc4126a0ed
+330
-126
@@ -1,153 +1,357 @@
|
||||
// A video in the Files viewer. First the file as it is, handed to the browser's own
|
||||
// decoder; when that fails — Safari and an MKV, HEVC without a hardware decoder — a
|
||||
// picker: codec and size, and the machine re-encodes it to HLS on its own GPU (or CPU),
|
||||
// which plays natively on Safari and through hls.js everywhere else. The same picker is
|
||||
// one tap away even when the file plays, for a 36 Mbps capture over a slow link.
|
||||
// A re-encode's timeline is the whole film from the start: seek anywhere, and the machine
|
||||
// starts encoding from there (repackaging, which is near-instant, grows as it goes instead).
|
||||
// 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 { api, apiPath, PlayCaps, PlayStatus, PlayStream } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
import Hls from 'hls.js'
|
||||
import { api, nodeAPI, type PlayOpen, type PlayStatus } from './api'
|
||||
import { canMotion, Sphere } from './vr'
|
||||
|
||||
type Mode = 'native' | 'pick' | 'hls'
|
||||
const CODEC_KEY = 'hamlab.play.codec'
|
||||
const QUALITY_KEY = 'hamlab.play.quality'
|
||||
const remember = (k: string, v: string) => { try { localStorage.setItem(k, v) } catch { /* private mode */ } }
|
||||
const recall = (k: string, d: string) => { try { return localStorage.getItem(k) || d } catch { return d } }
|
||||
type Choice = 'auto' | 'original' | number // a number is a width: the rung's name
|
||||
|
||||
const clock = (sec: number) => { const t = 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 fmtMbps = (bps?: number) => (bps ? `${(bps / 1e6).toFixed(bps >= 10e6 ? 0 : 1)} Mbps` : '')
|
||||
const describe = (s: PlayStream) => {
|
||||
const v = s.source.video
|
||||
const src = v ? `${v.codec}${v.profile ? ' ' + v.profile : ''} ${v.width}×${v.height}${v.fps ? ` ${Math.round(v.fps)} fps` : ''}${s.source.bit_rate ? ` ${fmtMbps(s.source.bit_rate)}` : ''} in ${s.source.container.split(',')[0]}` : s.source.container
|
||||
const out = s.codec === 'copy' ? 'repackaged' : `${s.codec === 'h264' ? 'H.264' : s.codec.toUpperCase()} ${s.width}×${s.height} ${(s.kbps / 1000).toFixed(s.kbps >= 10000 ? 0 : 1)} Mbps`
|
||||
return `${src} → ${out} on ${s.engine}`
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
export default function Player({ path, sudo, name }: { path: string; sudo: boolean; name: string }) {
|
||||
const [mode, setMode] = useState<Mode>('native')
|
||||
const [why, setWhy] = useState('') // what brought the picker up
|
||||
const [caps, setCaps] = useState<PlayCaps | null>(null)
|
||||
const [codec, setCodec] = useState(() => recall(CODEC_KEY, 'h264'))
|
||||
const [quality, setQuality] = useState(() => recall(QUALITY_KEY, '1080p'))
|
||||
const [stream, setStream] = useState<{ s: PlayStream; url: string } | null>(null)
|
||||
const [status, setStatus] = useState<PlayStatus | null>(null)
|
||||
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'
|
||||
|
||||
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 streamRef = useRef(stream)
|
||||
streamRef.current = stream
|
||||
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
|
||||
|
||||
useEffect(() => { setMode('native'); setWhy(''); setStream(null); setStatus(null); setErr('') }, [path, sudo])
|
||||
useEffect(() => { remember(CODEC_KEY, codec) }, [codec])
|
||||
useEffect(() => { remember(QUALITY_KEY, quality) }, [quality])
|
||||
useEffect(() => { if (mode === 'pick' && !caps) api.playCaps(sudo).then(setCaps).catch((e) => setCaps({ codecs: [], qualities: [], error: e instanceof Error ? e.message : 'failed' })) }, [mode, caps, sudo])
|
||||
|
||||
// a stream is the machine's work: let go of it when this player does — and when the page
|
||||
// itself goes, by a beacon, the one request a browser still sends on its way out
|
||||
const stop = useCallback(() => { const s = streamRef.current; if (s) { api.playStop(s.s.id).catch(() => { /* gone already */ }); setStream(null); setStatus(null) } }, [])
|
||||
useEffect(() => () => stop(), [stop])
|
||||
// Open the play; let it go when this player does, and when the page goes, by a beacon.
|
||||
useEffect(() => {
|
||||
const h = () => { const s = streamRef.current; if (s) navigator.sendBeacon(apiPath(`/api/files/play/${s.s.id}/close`)) }
|
||||
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 start = async () => {
|
||||
setErr('')
|
||||
try {
|
||||
stop()
|
||||
const r = await api.playStart(path, codec, quality, sudo)
|
||||
setStream({ s: r.stream, url: r.url }); setMode('hls')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
const info = open?.play.info
|
||||
const vr = !!info?.video?.spherical
|
||||
|
||||
// feed the stream to the element: Safari plays HLS itself, the rest through hls.js
|
||||
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 (mode !== 'hls' || !stream || !v) return
|
||||
const url = apiPath(stream.url)
|
||||
let hls: { destroy: () => void } | null = null
|
||||
let gone = false
|
||||
;(async () => {
|
||||
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
v.src = url
|
||||
} else {
|
||||
const { default: Hls } = await import('hls.js')
|
||||
if (gone) return
|
||||
if (!Hls.isSupported()) { setErr('this browser has neither HLS nor MediaSource — nothing can play here'); return }
|
||||
// a segment the machine hasn't made yet is made when asked for (a seek): its first
|
||||
// byte can take as long as the re-encode takes to start there and write it
|
||||
const slow = { maxTimeToFirstByteMs: 50000, maxLoadTimeMs: 120000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 } }
|
||||
const h = new Hls({ maxBufferLength: 60, backBufferLength: 30, fragLoadPolicy: { default: slow } })
|
||||
hls = h
|
||||
h.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) setErr(`playback failed: ${d.details}`) })
|
||||
h.loadSource(url)
|
||||
h.attachMedia(v)
|
||||
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.play().catch(() => { /* needs a tap on some phones */ })
|
||||
})()
|
||||
return () => { gone = true; hls?.destroy() }
|
||||
}, [mode, stream])
|
||||
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
|
||||
|
||||
// follow the re-encode while it runs
|
||||
// A hand-picked quality, or back to Auto, while hls.js is already playing.
|
||||
useEffect(() => {
|
||||
if (mode !== 'hls' || !stream) return
|
||||
let stopPolling = false
|
||||
const tick = async () => { try { const st = await api.playStatus(stream.s.id); if (!stopPolling) { setStatus(st); if (st.running) setTimeout(tick, 2000) } } catch { /* ended */ } }
|
||||
tick()
|
||||
return () => { stopPolling = true }
|
||||
}, [mode, stream])
|
||||
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])
|
||||
|
||||
const pick = (reason: string) => { setWhy(reason); setMode('pick') }
|
||||
const codecs = caps?.codecs ?? []
|
||||
// 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])
|
||||
const seekBy = useCallback((d: number) => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
v.currentTime = Math.max(0, Math.min(info?.duration ?? v.duration, v.currentTime + d))
|
||||
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 === '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 className="player">
|
||||
{mode === 'native' && (
|
||||
<>
|
||||
<div className="play-bar"><span>as it is</span><span className="spacer" /><button className="btn" onClick={() => pick('')}>Re-encode…</button></div>
|
||||
<div className="preview"><video controls playsInline preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => pick(`This browser can't play ${name} as it is — the container or codec isn't one it decodes.`)} /></div>
|
||||
</>
|
||||
)}
|
||||
{mode === 'pick' && (
|
||||
<div className="play-pick notice">
|
||||
<div>{why || `Re-encode ${name} on this machine and play that instead.`}</div>
|
||||
{caps?.error && <div className="error" style={{ marginTop: 8 }}>{caps.error}</div>}
|
||||
{!caps && !err && <div className="muted small" style={{ marginTop: 8 }}><Spinner /> asking the machine what it can encode…</div>}
|
||||
{caps && !caps.error && (
|
||||
<div className="row">
|
||||
<label>codec
|
||||
<select value={codec} onChange={(e) => setCodec(e.target.value)}>
|
||||
{codecs.map((c) => <option key={c.id} value={c.id} disabled={!c.engine}>{c.label}{c.engine ? ` — ${c.engine}` : ' — not on this machine'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>size
|
||||
<select value={quality} onChange={(e) => setQuality(e.target.value)} disabled={codec === 'copy'}>
|
||||
{caps.qualities.map((q) => <option key={q.id} value={q.id}>{q.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Btn className="btn on" disabled={!codecs.find((c) => c.id === codec)?.engine} onClick={start}>Play</Btn>
|
||||
<a className="btn" href={api.fileDownloadURL(path, sudo)} download={name}>Download instead</a>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
<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">
|
||||
<div className="vp-seek" style={{ ['--p' as string]: dur ? `${(shown / dur) * 100}%` : '0%', ['--b' as string]: dur ? `${(buffered / dur) * 100}%` : '0%' }}>
|
||||
<input type="range" min={0} max={dur || 1} step={0.1} value={shown} aria-label="Seek"
|
||||
onChange={(e) => { setScrub(Number(e.target.value)); poke() }}
|
||||
onPointerUp={(e) => { if (v) v.currentTime = Number((e.target as HTMLInputElement).value); setScrub(null) }}
|
||||
onKeyUp={(e) => { if (v) v.currentTime = Number((e.target as HTMLInputElement).value); setScrub(null) }} />
|
||||
</div>
|
||||
)}
|
||||
{mode === 'hls' && stream && (
|
||||
<>
|
||||
<div className="play-bar">
|
||||
<span title={describe(stream.s)}>{describe(stream.s)}</span>
|
||||
{status && status.running && (stream.s.seekable
|
||||
? <span>· {status.speed ? `${status.speed.toFixed(1)}× ` : ''}from {clock(status.from ?? 0)} · {Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% ready — seek anywhere</span>
|
||||
: <span>· {status.speed ? `${status.speed.toFixed(1)}×` : ''} {stream.s.source.duration ? `${Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% through` : `${Math.round(status.progress)} s done`} — seek within what's done</span>)}
|
||||
{status?.done && <span>· all of it is ready</span>}
|
||||
{status?.error && <span className="error">· stopped early: {status.error}</span>}
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={() => { stop(); setMode('pick'); setWhy('') }}>Change…</button>
|
||||
<div className="vp-row">
|
||||
<button className="vp-icon" onClick={toggle} aria-label={paused ? 'Play' : 'Pause'}>{paused ? <I d={PLAY} fill /> : <I d={PAUSE} />}</button>
|
||||
<span className="vp-time">{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 className="preview"><video ref={video} controls autoPlay playsInline /></div>
|
||||
{err && <div className="error" style={{ margin: 12 }}>{err}</div>}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user