/** * 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=) * 画质:官网 /live 写入 localStorage(yh_live_capture_quality) * 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream) */ const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '') const LIVE_CAPTURE_QUALITY_STORAGE_KEY = 'yh_live_capture_quality' const QUALITY_MEDIA = { source: { video: true, audio: true }, high: { video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, audio: true }, mid: { video: { width: { ideal: 854 }, height: { ideal: 480 }, frameRate: { ideal: 24 } }, audio: true }, low: { video: { width: { ideal: 640 }, height: { ideal: 360 }, frameRate: { ideal: 20 } }, audio: true } } function effectivePublishQualityKey() { try { const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY) if (v && QUALITY_MEDIA[v]) return v } catch (_) {} return 'source' } function liveWsURLPublish(token) { const q = effectivePublishQualityKey() const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}` if (apiBase) { const base = apiBase.replace(/\/$/, '') const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') return `${wsOrigin}${path}` } const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return `${proto}//${window.location.host}${path}` } const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }] const MAX_SIGNAL_RECONNECT = 15 function healthCheckUrl() { if (apiBase) return `${apiBase}/api/health` if (typeof window !== 'undefined') return `${window.location.origin}/api/health` return '/api/health' } function buildCameraConstraints(publishKey, videoDeviceId) { const preset = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source const dev = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : {} if (preset.video === true) { return { audio: true, video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true } } return { audio: true, video: { ...preset.video, ...dev } } } function humanizeGetUserMediaError(err) { const name = err && err.name const raw = ((err && err.message) || '').toLowerCase() if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { return '已拒绝摄像头或麦克风权限。' } if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { return '未检测到摄像头。' } if ( name === 'NotReadableError' || raw.includes('could not start video source') || raw.includes('failed to start video source') || raw.includes('video source') ) { return '摄像头被占用或不可用,请关闭其它使用相机的程序后重试。' } if (name === 'OverconstrainedError') { return '当前摄像头不满足画质约束,请在官网「直播」页改为「原画」或换摄像头。' } if (name === 'AbortError') { return '采集被中断,请重试。' } return (err && err.message) || '无法打开摄像头' } /** * @param {object} opts * @param {string} opts.token * @param {'camera'|'screen_pip'} [opts.captureMode] * @param {string} [opts.videoDeviceId] videoinput deviceId,空则默认设备 * @param {(s: string) => void} [opts.onStatus] * @param {(p: { main: MediaStream, pip: MediaStream|null }) => void} [opts.onLocalStream] */ export function startPublishing(opts = {}) { const { token = '', captureMode = 'camera', videoDeviceId = '', onStatus = () => {}, onLocalStream = () => {} } = opts if (!token) { onStatus('未登录,无法开播') return { stop: () => {} } } const publishKey = effectivePublishQualityKey() const wsUrl = liveWsURLPublish(token) let closedByLocal = false let stream = null let ws = null let pc = null let reconnectTimer = null let reconnectAttempt = 0 let wsGen = 0 let reconnectStopped = false function clearReconnectTimer() { if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } } const send = (o) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)) } async function acquireMedia() { if (captureMode === 'screen_pip') { let display try { display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }) } catch (e) { const name = e && e.name throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败') } const screenVideo = display.getVideoTracks()[0] if (!screenVideo) { display.getTracks().forEach((t) => t.stop()) throw new Error('未获得屏幕画面') } screenVideo.addEventListener('ended', () => { if (!closedByLocal) { onStatus('屏幕共享已结束') stop() } }) let cam try { cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId)) } catch (e) { screenVideo.stop() display.getTracks().forEach((t) => t.stop()) throw e } const camVideo = cam.getVideoTracks()[0] const mic = cam.getAudioTracks() const publish = new MediaStream([screenVideo, camVideo, ...mic]) const mainPrev = new MediaStream([screenVideo]) const pipPrev = new MediaStream([camVideo]) onLocalStream({ main: mainPrev, pip: pipPrev }) return publish } const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId)) onLocalStream({ main: s, pip: null }) return s } async function ensureStreamAndAttach() { const needNew = !stream || !stream.getTracks().length || stream.getTracks().some((t) => t.readyState !== 'live') if (needNew) { stream?.getTracks().forEach((t) => { try { t.stop() } catch (_) {} }) try { stream = await acquireMedia() } catch (e) { const msg = e && typeof e.message === 'string' && e.message ? e.message : humanizeGetUserMediaError(e) onStatus(msg) throw e } } if (pc) { try { pc.close() } catch (_) {} pc = null } pc = new RTCPeerConnection({ iceServers: defaultIce }) pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'ice', candidate: e.candidate.toJSON() }) } stream.getTracks().forEach((t) => { if (t.readyState === 'live') pc.addTrack(t, stream) }) const offer = await pc.createOffer() await pc.setLocalDescription(offer) send({ type: 'offer', sdp: offer.sdp }) onStatus('协商中…') } function onSocketMessage(ev) { let msg try { msg = JSON.parse(ev.data) } catch { return } if (msg.type === 'answer' && msg.sdp && pc) { pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }).then( () => { reconnectAttempt = 0 clearReconnectTimer() onStatus('直播中') }, (e) => { onStatus(e.message || '协商失败') } ) } if (msg.type === 'ice' && msg.candidate && pc) { try { pc.addIceCandidate(msg.candidate) } catch (_) {} } if (msg.type === 'error') { onStatus(msg.message || '服务端错误') } } function scheduleReconnect() { if (closedByLocal || reconnectStopped) return clearReconnectTimer() reconnectAttempt += 1 if (reconnectAttempt > MAX_SIGNAL_RECONNECT) { reconnectStopped = true onStatus( `信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 返回 502(进程未启动/崩溃)或 Nginx 未正确反代到 Go。请检查服务与 \`/api/web/live/ws\` 的 WebSocket 配置,修复后刷新本页再点「开始直播」。` ) return } const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt - 1), 28000) onStatus(`信令断开,约 ${Math.round(delay / 1000)} 秒后重试(${reconnectAttempt}/${MAX_SIGNAL_RECONNECT})…`) reconnectTimer = window.setTimeout(async () => { reconnectTimer = null if (closedByLocal || reconnectStopped) return try { const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' }) if (!r.ok) { onStatus(`API 不可用(HTTP ${r.status}),多为网关 502,推迟重试…`) } } catch (_) { onStatus('无法访问健康检查接口,请确认网络与域名。') } if (closedByLocal || reconnectStopped) return openSignalingSocket() }, delay) } function openSignalingSocket() { if (closedByLocal || reconnectStopped) return const myGen = ++wsGen clearReconnectTimer() if (ws) { try { ws.close() } catch (_) {} ws = null } if (pc) { try { pc.close() } catch (_) {} pc = null } try { ws = new WebSocket(wsUrl) } catch (_) { onStatus('无法连接信令') scheduleReconnect() return } ws.onopen = async () => { if (closedByLocal || myGen !== wsGen) return onStatus('采集中…') try { await ensureStreamAndAttach() } catch (err) { if (!closedByLocal) { onStatus(err?.message || humanizeGetUserMediaError(err)) stop() } } } ws.onmessage = onSocketMessage ws.onerror = () => { if (!closedByLocal) onStatus('信令异常') } ws.onclose = () => { if (myGen !== wsGen) return ws = null if (pc) { try { pc.close() } catch (_) {} pc = null } if (closedByLocal) return scheduleReconnect() } } function stop() { closedByLocal = true reconnectStopped = true wsGen += 1 clearReconnectTimer() if (ws) { try { ws.close() } catch (_) {} ws = null } if (pc) { try { pc.close() } catch (_) {} pc = null } stream?.getTracks().forEach((t) => { try { t.stop() } catch (_) {} }) stream = null } openSignalingSocket() return { stop } }