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 (