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
+1
-1
@@ -267,7 +267,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||
)}
|
||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||
{/* Video: the browser's own decoder first, and a re-encode on the machine when that fails. Audio as it is. */}
|
||||
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
||||
|
||||
+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>
|
||||
)
|
||||
|
||||
+58
-1
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './sensors'
|
||||
import { api, Me, TokenInfo, UpdateStatus } from './api'
|
||||
import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api'
|
||||
import { savePrefs } from './accents'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||
@@ -104,6 +104,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TranscodeDevice />
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<section className="card">
|
||||
@@ -248,3 +249,59 @@ function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { n
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Where videos played in Files are made: one device on one machine, for every machine's files.
|
||||
// Every machine's devices are asked for live; one that can't be used says why.
|
||||
function TranscodeDevice() {
|
||||
const [t, setT] = useState<TranscodeSetting | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
useEffect(() => { api.transcode().then(setT).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const set = async (node: string, device: string) => {
|
||||
setBusy(node + '|' + device); setErr('')
|
||||
try { setT(await api.setTranscode(node, device)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
setBusy('')
|
||||
}
|
||||
const sel = t?.selected
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Transcode device</h2><span className="spacer" /><span className="muted small">videos in Files, on every machine</span></div>
|
||||
<section className="card">
|
||||
{!t && !err && <div className="muted small"><Spinner /> asking every machine what it can encode on…</div>}
|
||||
{t && (
|
||||
<div className="tc-list">
|
||||
<div className="tc-machine">
|
||||
<div className="tc-host">Anywhere</div>
|
||||
<div className="chips">
|
||||
<button className={'chip' + (!sel ? ' on' : '')} disabled={!!busy} onClick={() => set('', '')}>The machine the file is on, its best device</button>
|
||||
</div>
|
||||
</div>
|
||||
{t.machines.map((m) => (
|
||||
<div key={m.node} className="tc-machine">
|
||||
<div className="tc-host">{m.host}{m.node === '' && <span className="muted small"> · this hub</span>}</div>
|
||||
{m.error && <div className="muted small">{m.error}</div>}
|
||||
<div className="chips">
|
||||
{(m.devices ?? []).map((d) => {
|
||||
const on = !!sel && sel.node === m.node && sel.device === d.id
|
||||
return (
|
||||
<button key={d.id} className={'chip' + (on ? ' on' : '') + (d.ok ? '' : ' off')} disabled={!d.ok || !!busy} title={d.ok ? d.id : d.reason}
|
||||
onClick={() => set(m.node, d.id)}>
|
||||
{busy === m.node + '|' + d.id ? <Spinner size={10} /> : null}{d.name}{d.id !== 'cpu' && d.integrated ? ' (integrated)' : ''}{!d.ok && d.reason ? ` — ${d.reason}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
<div className="muted small" style={{ marginTop: 10 }}>
|
||||
Opening a video in Files plays it YouTube's way — Auto picks the quality the connection takes, any quality can be picked by hand,
|
||||
or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. When it is on another
|
||||
machine than the file, that machine reads the file through this hub. If it can't be reached, the file's own machine makes it instead.
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+25
-12
@@ -4,7 +4,12 @@ export class Unauthenticated extends Error { constructor(msg = 'unauthenticated'
|
||||
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
||||
// the machine list itself) pass local=true and are never rewritten.
|
||||
let base = ''
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : ''; machine = name ?? '' }
|
||||
let machine = ''
|
||||
// The machine the console is looking at: '' is this host.
|
||||
export const currentMachine = () => machine
|
||||
// A path under another machine's /api, whichever one the console is looking at.
|
||||
export const nodeAPI = (node: string, p: string) => (node && p.startsWith('/api/') ? `/api/nodes/${node}${p.slice(4)}` : p)
|
||||
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
||||
|
||||
// A machine that just failed to answer is left alone for a few seconds instead of
|
||||
@@ -250,12 +255,18 @@ export interface ARCDetail {
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
||||
|
||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | 'audio' }
|
||||
export interface PlayCaps { codecs: { id: string; label: string; engine?: string }[]; qualities: { id: string; label: string; height: number }[]; error?: string }
|
||||
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number }
|
||||
export interface PlayStream { id: string; codec: string; quality: string; engine: string; width: number; height: number; kbps: number; seekable?: boolean; source: { container: string; duration: number; bit_rate?: number; video?: PlayTrack; audio?: PlayTrack } }
|
||||
// progress: seconds written (repackaged), or seconds of the film on disk (a seekable re-encode,
|
||||
// whose run now going started at `from`)
|
||||
export interface PlayStatus { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number; from?: number }
|
||||
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number; spherical?: boolean }
|
||||
export interface PlayRung { name: string; label: string; width: number; height: number; fps: number; bitrate: number; peak: number }
|
||||
export interface PlayStatus { engine?: string; quality?: string; from: number; ahead: number; paused?: boolean }
|
||||
// A play: where it is made (node, '' the hub), its qualities best first, and the playlist under
|
||||
// that machine's /api.
|
||||
export interface PlayOpen {
|
||||
node: string; master: string; engine: string; host: string; note?: string; status: PlayStatus
|
||||
play: { id: string; name: string; device: string; remote: boolean; rungs: PlayRung[]; info: { container: string; duration: number; bit_rate?: number; size?: number; video?: PlayTrack; audio?: PlayTrack } }
|
||||
}
|
||||
export interface TranscodeDevice { id: string; name: string; integrated?: boolean; ok: boolean; reason?: string }
|
||||
export interface TranscodeMachine { node: string; host: string; devices?: TranscodeDevice[]; error?: string }
|
||||
export interface TranscodeSetting { selected: { node: string; device: string } | null; machines: TranscodeMachine[] }
|
||||
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
||||
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
||||
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
||||
@@ -281,6 +292,13 @@ export const api = {
|
||||
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
|
||||
return r.text()
|
||||
},
|
||||
// Playing: opened on the hub, which picks the machine (the Transcode device setting); the
|
||||
// rest goes to that machine.
|
||||
play: (path: string, sudo: boolean) => req<PlayOpen>('POST', '/api/play', { node: currentMachine(), path, sudo }, true),
|
||||
playStatus: (node: string, id: string) => req<PlayStatus>('GET', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||
playStop: (node: string, id: string) => req<void>('DELETE', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||
transcode: () => req<TranscodeSetting>('GET', '/api/transcode', undefined, true),
|
||||
setTranscode: (node: string, device: string) => req<TranscodeSetting>('PUT', '/api/transcode', device ? { node, device } : {}, true),
|
||||
fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
|
||||
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
|
||||
// v carries the file's mtime and size, so a changed file is a new URL and an unchanged one a cached one
|
||||
@@ -288,11 +306,6 @@ export const api = {
|
||||
thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`),
|
||||
thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'),
|
||||
thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'),
|
||||
// playback of what the browser can't play as it is: an HLS re-encode on the machine
|
||||
playCaps: (sudo: boolean) => req<PlayCaps>('GET', `/api/files/play-caps${sudo ? '?sudo=1' : ''}`),
|
||||
playStart: (path: string, codec: string, quality: string, sudo: boolean) => req<{ stream: PlayStream; url: string }>('POST', `/api/files/play${sudo ? '?sudo=1' : ''}`, { path, codec, quality }),
|
||||
playStatus: (id: string) => req<PlayStatus>('GET', `/api/files/play/${id}`),
|
||||
playStop: (id: string) => req<void>('DELETE', `/api/files/play/${id}`),
|
||||
fileWrite: async (path: string, text: string, sudo: boolean) => {
|
||||
const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||
if (r.status === 401) throw new Unauthenticated()
|
||||
|
||||
+45
-7
@@ -145,6 +145,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
||||
.chip.off { opacity: .55; cursor: default; } .chip.off:hover { color: var(--muted); border-color: var(--border); }
|
||||
.tc-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.tc-host { font-weight: 600; margin-bottom: 6px; }
|
||||
.svc-rows { flex: 1; overflow-y: auto; }
|
||||
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
@@ -365,13 +368,48 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
||||
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
||||
.player { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.player .preview { flex: 1; min-height: 0; }
|
||||
.play-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 12px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
|
||||
.play-bar select { padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12px; }
|
||||
.play-pick { margin: 12px; }
|
||||
.play-pick .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.play-pick label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||
on an iPhone the whole window */
|
||||
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||
.vp.max { position: fixed; inset: 0; z-index: 1000; }
|
||||
.vp-v { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
|
||||
.vp-hidden { opacity: 0; pointer-events: none; }
|
||||
.vp-sphere { touch-action: none; cursor: grab; }
|
||||
.vp-sphere:active { cursor: grabbing; }
|
||||
.vp-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 26px 8px calc(env(safe-area-inset-bottom) + 4px); background: linear-gradient(transparent, rgba(0, 0, 0, .75)); transition: opacity .25s; z-index: 2; }
|
||||
.vp.bare .vp-bottom, .vp.bare .vp-hint { opacity: 0; pointer-events: none; }
|
||||
.vp.bare { cursor: none; }
|
||||
.vp-row { display: flex; align-items: center; gap: 4px; }
|
||||
.vp-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 19px; color: #fff; }
|
||||
.vp-icon:hover { background: rgba(255, 255, 255, .14); }
|
||||
.vp-icon.on { color: var(--accent); }
|
||||
.vp-time { font-size: 13px; font-variant-numeric: tabular-nums; padding: 0 6px; white-space: nowrap; }
|
||||
.vp-q { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 17px; font-size: 13px; font-weight: 600; white-space: nowrap; color: #fff; }
|
||||
.vp-q:hover { background: rgba(255, 255, 255, .14); }
|
||||
.vp-seek { position: relative; height: 24px; margin: 0 8px; display: flex; align-items: center; }
|
||||
.vp-seek::before { content: ''; position: absolute; left: 0; right: 0; height: 4px; border-radius: 2px;
|
||||
background: linear-gradient(to right, var(--accent) var(--p), rgba(255, 255, 255, .45) var(--p) var(--b), rgba(255, 255, 255, .2) var(--b)); }
|
||||
.vp-seek input { position: relative; width: 100%; margin: 0; background: transparent; border: 0; padding: 0; height: 24px; -webkit-appearance: none; appearance: none; cursor: pointer; }
|
||||
.vp-seek input::-webkit-slider-runnable-track { height: 4px; background: transparent; }
|
||||
.vp-seek input::-moz-range-track { height: 4px; background: transparent; }
|
||||
.vp-seek input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; margin-top: -5px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||
.vp-seek input::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||
.vp-big { position: absolute; left: 50%; top: 50%; width: 68px; height: 68px; margin: -34px 0 0 -34px; border-radius: 50%; display: grid; place-items: center; background: rgba(0, 0, 0, .55); color: #fff; z-index: 2; padding-left: 4px; }
|
||||
.vp-spin { position: absolute; left: 50%; top: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .2); border-top-color: var(--accent); animation: vp-spin .8s linear infinite; z-index: 1; pointer-events: none; }
|
||||
@keyframes vp-spin { to { transform: rotate(360deg); } }
|
||||
.vp-err { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; z-index: 1; }
|
||||
.vp-err p { margin: 0; max-width: 460px; line-height: 1.45; }
|
||||
.vp-note { position: absolute; left: 50%; top: 14px; transform: translateX(-50%); max-width: min(92%, 480px); padding: 9px 13px; border-radius: 8px; background: rgba(20, 20, 20, .92); font-size: 13px; line-height: 1.4; z-index: 3; cursor: pointer; }
|
||||
.vp-hint { position: absolute; left: 50%; bottom: 86px; transform: translateX(-50%); padding: 5px 11px; border-radius: 14px; background: rgba(0, 0, 0, .55); font-size: 12px; pointer-events: none; transition: opacity .25s; z-index: 1; white-space: nowrap; }
|
||||
.vp-menu { position: absolute; right: 8px; bottom: 60px; width: min(290px, calc(100% - 16px)); max-height: calc(100% - 76px); overflow-y: auto; padding: 6px; border-radius: 10px; background: rgba(24, 24, 24, .96); box-shadow: 0 8px 30px rgba(0, 0, 0, .5); z-index: 4; }
|
||||
.vp-menu-h { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: rgba(255, 255, 255, .55); padding: 6px 10px 4px; }
|
||||
.vp-menu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border-radius: 6px; text-align: left; font-size: 14px; color: #fff; }
|
||||
.vp-menu button:hover:not(:disabled) { background: rgba(255, 255, 255, .1); }
|
||||
.vp-menu button:disabled { opacity: .45; }
|
||||
.vp-menu button small { margin-left: auto; color: rgba(255, 255, 255, .6); font-size: 12px; white-space: nowrap; }
|
||||
.vp-menu .ck { width: 16px; flex: none; color: var(--accent); display: grid; place-items: center; }
|
||||
.vp-menu-f { border-top: 1px solid rgba(255, 255, 255, .12); margin-top: 6px; padding: 8px 10px 4px; font-size: 12px; color: rgba(255, 255, 255, .6); line-height: 1.4; }
|
||||
.vp-menu-f b { color: #fff; font-weight: 600; }
|
||||
.preview.audio { align-items: flex-start; }
|
||||
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
|
||||
.audio-box .ficon { font-size: 48px; }
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
// The 360° view: an equirectangular video drawn onto a canvas as the flat picture a person
|
||||
// would see looking out from the middle of it. Drag (or swipe) to look around, pinch or scroll
|
||||
// to zoom, and on a phone, optionally, turn the phone. One full-canvas triangle pair; the
|
||||
// fragment shader works out, for each pixel, which way it looks and where that is in the video.
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 a;
|
||||
varying vec2 p;
|
||||
void main() { p = a; gl_Position = vec4(a, 0.0, 1.0); }`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D tex;
|
||||
uniform mat3 view; // columns: right, up, forward
|
||||
uniform vec2 aspect; // the screen's sides over its taller side: (w/h, 1) or (1, h/w)
|
||||
uniform float fov; // radians, across the taller side
|
||||
varying vec2 p;
|
||||
const float PI = 3.14159265358979;
|
||||
void main() {
|
||||
vec2 q = p * aspect;
|
||||
// Narrow: an ordinary lens. Wide: stereographic, which goes on past 180 degrees to the
|
||||
// "little planet". Between 100 and 140 degrees one turns into the other.
|
||||
vec3 rect = normalize(vec3(q * tan(min(fov, 3.0) * 0.5), 1.0));
|
||||
float r = length(q) * 2.0 * tan(fov * 0.25);
|
||||
float th = 2.0 * atan(r * 0.5);
|
||||
vec2 dir = r > 0.0 ? q / length(q) : vec2(0.0);
|
||||
vec3 stereo = vec3(dir * sin(th), cos(th));
|
||||
float t = smoothstep(1.745, 2.443, fov);
|
||||
vec3 d = normalize(view * normalize(mix(rect, stereo, t)));
|
||||
float lon = atan(d.x, -d.z);
|
||||
float lat = asin(clamp(d.y, -1.0, 1.0));
|
||||
gl_FragColor = texture2D(tex, vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI));
|
||||
}`
|
||||
|
||||
// From a close look at something far off to the whole sphere as a little planet.
|
||||
const MIN_FOV = 8, MAX_FOV = 300
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
type Quat = [number, number, number, number] // x, y, z, w
|
||||
|
||||
function qmul(a: Quat, b: Quat): Quat {
|
||||
const [ax, ay, az, aw] = a, [bx, by, bz, bw] = b
|
||||
return [aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz]
|
||||
}
|
||||
function qrot(q: Quat, v: Vec3): Vec3 {
|
||||
const [x, y, z, w] = q
|
||||
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
|
||||
return [ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x]
|
||||
}
|
||||
const axis = (v: Vec3, rad: number): Quat => { const s = Math.sin(rad / 2); return [v[0] * s, v[1] * s, v[2] * s, Math.cos(rad / 2)] }
|
||||
|
||||
// The phone's orientation as a camera's, the way the W3C spec defines the angles: Z-X'-Y''
|
||||
// intrinsic, then turned to look out of the back of the phone, then for the screen's rotation.
|
||||
function deviceQuat(alpha: number, beta: number, gamma: number, screen: number): Quat {
|
||||
const d = Math.PI / 180
|
||||
const [a, b, g] = [alpha * d, beta * d, gamma * d]
|
||||
let q = qmul(qmul(axis([0, 1, 0], a), axis([1, 0, 0], b)), axis([0, 0, 1], -g)) // YXZ, in three.js's frame
|
||||
q = qmul(q, [-Math.SQRT1_2, 0, 0, Math.SQRT1_2])
|
||||
return qmul(q, axis([0, 0, 1], -screen * d))
|
||||
}
|
||||
|
||||
export class Sphere {
|
||||
yaw = 0 // degrees, + is right
|
||||
pitch = 0 // degrees, + is up
|
||||
fov = 80 // degrees, the taller of the two sides
|
||||
private gl: WebGLRenderingContext
|
||||
private tex: WebGLTexture
|
||||
private uView: WebGLUniformLocation
|
||||
private uAspect: WebGLUniformLocation
|
||||
private uFov: WebGLUniformLocation
|
||||
private raf = 0
|
||||
private frameCb = 0
|
||||
private dirty = true
|
||||
private hasFrame = false
|
||||
private gyro: { alpha: number; beta: number; gamma: number } | null = null
|
||||
private gyroYaw = 0 // what dragging adds to the phone's own heading
|
||||
private ro: ResizeObserver
|
||||
maxTexture: number
|
||||
|
||||
constructor(private canvas: HTMLCanvasElement, private video: HTMLVideoElement) {
|
||||
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, preserveDrawingBuffer: false })
|
||||
if (!gl) throw new Error('This browser can’t draw 360° video (no WebGL).')
|
||||
this.gl = gl
|
||||
this.maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
|
||||
const sh = (type: number, src: string) => {
|
||||
const s = gl.createShader(type)!
|
||||
gl.shaderSource(s, src); gl.compileShader(s)
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader')
|
||||
return s
|
||||
}
|
||||
const prog = gl.createProgram()!
|
||||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG))
|
||||
gl.linkProgram(prog); gl.useProgram(prog)
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
|
||||
const loc = gl.getAttribLocation(prog, 'a')
|
||||
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
|
||||
this.tex = gl.createTexture()!
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex)
|
||||
// No mipmaps (a video changes every frame) and clamped: any size of picture will do.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0]))
|
||||
this.uView = gl.getUniformLocation(prog, 'view')!
|
||||
this.uAspect = gl.getUniformLocation(prog, 'aspect')!
|
||||
this.uFov = gl.getUniformLocation(prog, 'fov')!
|
||||
this.ro = new ResizeObserver(() => { this.dirty = true })
|
||||
this.ro.observe(canvas)
|
||||
this.watchFrames()
|
||||
const loop = () => { this.draw(); this.raf = requestAnimationFrame(loop) }
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
// A new picture from the video: on the frame callback where there is one, else every draw
|
||||
// while it plays.
|
||||
private watchFrames() {
|
||||
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }
|
||||
if (v.requestVideoFrameCallback) {
|
||||
const cb = () => { this.upload(); this.frameCb = v.requestVideoFrameCallback!(cb) }
|
||||
this.frameCb = v.requestVideoFrameCallback(cb)
|
||||
}
|
||||
v.addEventListener('loadeddata', this.upload)
|
||||
v.addEventListener('seeked', this.upload)
|
||||
}
|
||||
|
||||
private upload = () => {
|
||||
const v = this.video
|
||||
if (v.readyState < 2 || !v.videoWidth) return
|
||||
const gl = this.gl
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex)
|
||||
try {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, v)
|
||||
this.hasFrame = true
|
||||
this.dirty = true
|
||||
} catch { /* not ready yet */ }
|
||||
}
|
||||
|
||||
private draw() {
|
||||
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: unknown }
|
||||
if (!v.requestVideoFrameCallback && !v.paused) this.upload()
|
||||
if (!this.dirty && !this.gyro) return
|
||||
this.dirty = false
|
||||
const c = this.canvas, dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr)
|
||||
if (!w || !h) return
|
||||
if (c.width !== w || c.height !== h) { c.width = w; c.height = h }
|
||||
const gl = this.gl
|
||||
gl.viewport(0, 0, w, h)
|
||||
if (!this.hasFrame) { gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return }
|
||||
// The field of view applies to the taller side; on a phone held upright that is the height.
|
||||
let right: Vec3, up: Vec3, fwd: Vec3
|
||||
if (this.gyro) {
|
||||
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
|
||||
right = qrot(q, [1, 0, 0]); up = qrot(q, [0, 1, 0]); fwd = qrot(q, [0, 0, -1])
|
||||
} else {
|
||||
const y = (this.yaw * Math.PI) / 180, p = (this.pitch * Math.PI) / 180
|
||||
fwd = [Math.sin(y) * Math.cos(p), Math.sin(p), -Math.cos(y) * Math.cos(p)]
|
||||
right = [Math.cos(y), 0, Math.sin(y)]
|
||||
up = [right[1] * fwd[2] - right[2] * fwd[1], right[2] * fwd[0] - right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]]
|
||||
}
|
||||
// d = right·x + up·y + fwd·1, column-major
|
||||
gl.uniformMatrix3fv(this.uView, false, new Float32Array([...right, ...up, ...fwd]))
|
||||
gl.uniform2f(this.uAspect, w >= h ? 1 : w / h, w >= h ? h / w : 1)
|
||||
gl.uniform1f(this.uFov, (this.fov * Math.PI) / 180)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
}
|
||||
|
||||
// Dragging by a pixel turns the view by the angle a pixel covers, so what is under the finger
|
||||
// stays under it.
|
||||
drag(dx: number, dy: number) {
|
||||
const per = this.fov / Math.max(this.canvas.clientWidth, this.canvas.clientHeight)
|
||||
if (this.gyro) this.gyroYaw = (this.gyroYaw - dx * per) % 360
|
||||
else {
|
||||
this.yaw = (this.yaw - dx * per) % 360
|
||||
this.pitch = Math.max(-89, Math.min(89, this.pitch + dy * per))
|
||||
}
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
zoom(factor: number) {
|
||||
this.fov = Math.max(MIN_FOV, Math.min(MAX_FOV, this.fov * factor))
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
get gyroOn() { return this.gyro !== null }
|
||||
|
||||
private onOrient = (e: DeviceOrientationEvent) => {
|
||||
if (e.alpha === null || e.beta === null || e.gamma === null) return
|
||||
if (!this.gyro) {
|
||||
// Keep looking where the drag left off: the phone's heading now is the drag's yaw.
|
||||
const q = deviceQuat(e.alpha, e.beta, e.gamma, screenAngle())
|
||||
const f = qrot(q, [0, 0, -1])
|
||||
this.gyroYaw = this.yaw - (Math.atan2(f[0], -f[2]) * 180) / Math.PI
|
||||
}
|
||||
this.gyro = { alpha: e.alpha, beta: e.beta, gamma: e.gamma }
|
||||
}
|
||||
|
||||
// Turning on motion asks for permission on an iPhone, which must happen in a tap.
|
||||
async setGyro(on: boolean): Promise<boolean> {
|
||||
if (!on) {
|
||||
window.removeEventListener('deviceorientation', this.onOrient)
|
||||
if (this.gyro) {
|
||||
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
|
||||
const f = qrot(q, [0, 0, -1])
|
||||
this.yaw = (Math.atan2(f[0], -f[2]) * 180) / Math.PI
|
||||
this.pitch = (Math.asin(Math.max(-1, Math.min(1, f[1]))) * 180) / Math.PI
|
||||
}
|
||||
this.gyro = null
|
||||
this.dirty = true
|
||||
return false
|
||||
}
|
||||
const D = DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<string> }
|
||||
if (D.requestPermission) {
|
||||
try { if ((await D.requestPermission()) !== 'granted') return false } catch { return false }
|
||||
}
|
||||
window.addEventListener('deviceorientation', this.onOrient)
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this.raf)
|
||||
const v = this.video as HTMLVideoElement & { cancelVideoFrameCallback?: (n: number) => void }
|
||||
if (this.frameCb && v.cancelVideoFrameCallback) v.cancelVideoFrameCallback(this.frameCb)
|
||||
v.removeEventListener('loadeddata', this.upload)
|
||||
v.removeEventListener('seeked', this.upload)
|
||||
window.removeEventListener('deviceorientation', this.onOrient)
|
||||
this.ro.disconnect()
|
||||
this.gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
}
|
||||
|
||||
function screenAngle(): number {
|
||||
const o = screen.orientation?.angle
|
||||
if (typeof o === 'number') return o
|
||||
return (window as unknown as { orientation?: number }).orientation ?? 0
|
||||
}
|
||||
|
||||
export const canMotion = () => typeof DeviceOrientationEvent !== 'undefined' && matchMedia('(pointer: coarse)').matches
|
||||
Reference in New Issue
Block a user