/** * 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致) */ const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '') function liveWsURLPublish(token) { const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}` 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' }] /** 将 getUserMedia 异常转为中文说明(含 Edge 英文 “Could not start video source”) */ function humanizeGetUserMediaError(err) { const name = err && err.name const raw = ((err && err.message) || '').toLowerCase() if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { return '已拒绝摄像头或麦克风权限:在浏览器地址栏左侧允许摄像头与麦克风,并确认本页为 HTTPS。' } 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 '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。' } if (name === 'OverconstrainedError') { return '摄像头不满足当前参数约束,请换用其他摄像头或更新驱动。' } if (name === 'AbortError') { return '打开摄像头被系统中断,请重试。' } return (err && err.message) || '无法打开摄像头' } /** * @param {object} opts * @param {string} opts.token 管理员 JWT * @param {(s: string) => void} [opts.onStatus] * @param {(stream: MediaStream) => void} [opts.onLocalStream] */ export function startPublishing(opts = {}) { const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts if (!token) { onStatus('未登录,无法开播') return { stop: () => {} } } const wsUrl = liveWsURLPublish(token) const ws = new WebSocket(wsUrl) const pc = new RTCPeerConnection({ iceServers: defaultIce }) let stream = null /** 本地主动 stop / 摄像头失败关闭,避免 onclose 覆盖真实错误提示 */ let closedByLocal = false const send = (o) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)) } pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'ice', candidate: e.candidate.toJSON() }) } ws.onopen = async () => { onStatus('信令已连接,正在采集摄像头…') try { // 后台多为 PC:不要用 facingMode:'user',部分机器会直接导致 “Could not start video source” stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }) onLocalStream(stream) stream.getTracks().forEach((t) => pc.addTrack(t, stream)) const offer = await pc.createOffer() await pc.setLocalDescription(offer) send({ type: 'offer', sdp: offer.sdp }) onStatus('已发起推流协商,等待服务端应答…') } catch (err) { onStatus(humanizeGetUserMediaError(err)) stop() } } ws.onmessage = async (ev) => { let msg try { msg = JSON.parse(ev.data) } catch { return } if (msg.type === 'answer' && msg.sdp) { try { await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }) onStatus('直播中(请勿关闭本页;关闭即结束本场)') } catch (e) { onStatus(e.message || '设置远端描述失败') } } if (msg.type === 'ice' && msg.candidate) { try { await pc.addIceCandidate(msg.candidate) } catch (_) {} } if (msg.type === 'error') { onStatus(msg.message || '服务端错误') } } ws.onerror = () => { if (!closedByLocal) { onStatus('信令连接失败(请确认已登录且 Nginx 已配置 WebSocket)') } } ws.onclose = () => { if (closedByLocal) return onStatus('信令已断开:服务端关闭连接或网络中断。若刚能连上即断,请查服务端日志或配置 TURN(LIVE_ICE_SERVERS)。') } function stop() { closedByLocal = true try { ws.close() } catch (_) {} pc.getSenders().forEach((s) => { try { s.track?.stop() } catch (_) {} }) pc.close() stream?.getTracks().forEach((t) => t.stop()) } return { pc, ws, stop } }