121 lines
6.4 KiB
JavaScript
121 lines
6.4 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import Hls from 'hls.js'
|
|
import { whepUrl, hlsUrl } from './api.js'
|
|
import { audio, effectiveMuted } from './useAudio.js'
|
|
import { names } from './names.js'
|
|
|
|
// WebRTC (WHEP) first, HLS fallback. Mute/volume come from the shared audio store.
|
|
export default function Player({ params, api }) {
|
|
const { id, user, title } = params
|
|
const custom = names.use(s => s.streams[id]) // viewer-side rename (tab double-click)
|
|
const whep = params.whep || id // Opus audio mirror path when the source audio is AAC
|
|
const videoRef = useRef(null)
|
|
const [status, setStatus] = useState('connecting…')
|
|
const [viewers, setViewers] = useState(params.viewers ?? 0)
|
|
const wrapRef = useRef(null)
|
|
const [dims, setDims] = useState(null) // {w, h, gameH}: video size + game height (h minus the timecode band)
|
|
const [box, setBox] = useState(null) // {W, H}: game-aspect frame fitted into the panel; video overflows it at the bottom
|
|
const state = audio.use()
|
|
const muted = effectiveMuted(state, id)
|
|
const volume = state.streams[id]?.volume ?? 0.8
|
|
const override = state.streams[id]?.override
|
|
const focused = state.focused === id
|
|
|
|
useEffect(() => { audio.ensure(id) }, [id])
|
|
// size the frame: fit a box of the GAME aspect (video width / height-without-band) into the panel, letterboxed.
|
|
// The video is laid across the full box width, so its band rows fall below the box and are clipped.
|
|
useEffect(() => {
|
|
const el = wrapRef.current; if (!el) return
|
|
const fit = () => {
|
|
if (!dims) { setBox(null); return }
|
|
const cw = el.clientWidth, ch = el.clientHeight; if (!cw || !ch) return
|
|
const a = dims.w / dims.gameH
|
|
const W = cw / ch > a ? Math.floor(ch * a) : cw
|
|
setBox({ W, H: Math.floor(W / a) })
|
|
}
|
|
fit(); const ro = new ResizeObserver(fit); ro.observe(el); return () => ro.disconnect()
|
|
}, [dims])
|
|
const onSize = e => {
|
|
const v = e.currentTarget; const w = v.videoWidth, h = v.videoHeight; if (!w || !h) return
|
|
let band = Math.round(w / 160); band += band % 2
|
|
// only treat the extra rows as a band if what remains is a standard game height
|
|
const gameH = [720, 1080, 1440, 2160].includes(h - band) ? h - band : h
|
|
setDims(d => (d && d.w === w && d.h === h) ? d : { w, h, gameH })
|
|
}
|
|
useEffect(() => { const d = api.onDidActiveChange?.(() => {}) ; return () => d?.dispose?.() }, [api])
|
|
useEffect(() => { const t = setInterval(() => setViewers(params.viewers ?? 0), 2000); return () => clearInterval(t) }, [params.viewers])
|
|
|
|
// apply audio state to the element
|
|
useEffect(() => {
|
|
const v = videoRef.current; if (!v) return
|
|
v.muted = muted; v.volume = volume
|
|
if (!muted && v.paused) v.play().catch(() => {})
|
|
}, [muted, volume])
|
|
|
|
// connect
|
|
useEffect(() => {
|
|
const v = videoRef.current; if (!v) return
|
|
let pc, hls, cancelled = false
|
|
const startHls = () => {
|
|
if (cancelled) return
|
|
setStatus('hls')
|
|
if (Hls.isSupported()) {
|
|
hls = new Hls({ lowLatencyMode: true, liveSyncDurationCount: 2, enableWorker: true })
|
|
hls.loadSource(hlsUrl(id)); hls.attachMedia(v)
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}))
|
|
hls.on(Hls.Events.ERROR, (_, d) => { if (d.fatal) setStatus('hls error: ' + d.details) })
|
|
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
|
v.src = hlsUrl(id); v.play().catch(() => {})
|
|
} else setStatus('no playback method')
|
|
}
|
|
const startWhep = async () => {
|
|
try {
|
|
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
|
pc.addTransceiver('video', { direction: 'recvonly' })
|
|
pc.addTransceiver('audio', { direction: 'recvonly' })
|
|
pc.ontrack = e => { if (v.srcObject !== e.streams[0]) { v.srcObject = e.streams[0]; v.play().catch(() => {}) } }
|
|
pc.onconnectionstatechange = () => {
|
|
if (pc.connectionState === 'connected') setStatus('')
|
|
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState) && !cancelled) { pc.close(); startHls() }
|
|
}
|
|
const offer = await pc.createOffer()
|
|
await pc.setLocalDescription(offer)
|
|
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 1500); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
|
|
const r = await fetch(whepUrl(whep), { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
|
|
if (!r.ok) throw new Error('whep ' + r.status)
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
|
|
setStatus('webrtc')
|
|
} catch (e) {
|
|
if (cancelled) return
|
|
try { pc?.close() } catch {}
|
|
startHls()
|
|
}
|
|
}
|
|
startWhep()
|
|
return () => { cancelled = true; try { pc?.close() } catch {}; try { hls?.destroy() } catch {}; v.srcObject = null; v.removeAttribute('src') }
|
|
}, [id, whep])
|
|
|
|
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
|
|
return (
|
|
<div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}>
|
|
<div className="vwrap" ref={wrapRef}>
|
|
<div className="frame" style={box ? { width: box.W, height: box.H } : undefined}>
|
|
<video ref={videoRef} autoPlay playsInline muted onLoadedMetadata={onSize} onResize={onSize} />
|
|
</div>
|
|
</div>
|
|
{status && <div className="status">{status}</div>}
|
|
<div className="bar">
|
|
<span className="live">LIVE</span>
|
|
<span className="who">{user}</span>
|
|
<span className="title" title={title}>{custom || title}</span>
|
|
<span className="viewers">👁 {viewers}</span>
|
|
<button className={'mute' + (!muted ? ' on' : '') + (override === 'unmuted' && !focused ? ' pinned' : '')}
|
|
title={override ? (override === 'unmuted' ? 'audio pinned on (click to mute)' : 'muted (click to unmute)') : (focused ? 'focused: audio on (click to mute)' : 'muted: click to pin audio on')}
|
|
onMouseDown={e => e.stopPropagation()} onClick={() => audio.toggleMute(id)}>{icon}</button>
|
|
<input type="range" min="0" max="1" step="0.02" value={volume} onMouseDown={e => e.stopPropagation()}
|
|
onChange={e => audio.setVolume(id, parseFloat(e.target.value))} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|