/** * 管理后台 WebRTC 开播(观众端始终单路视频) * - camera:仅摄像头 * - screen_only:仅共享屏幕 + 麦克风 * - screen_pip:屏幕与摄像头 Canvas 合成;小窗位置由 getPipRect() 归一化矩形 (nx,ny,nw,nh) 决定,与预览拖动一致 * - 直播中可 switchMode 切换,无需结束直播 */ 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 } } // 码率上限(kbps): 在实时性与并发之间取平衡 const QUALITY_VIDEO_MAX_KBPS = { source: 1800, high: 1400, mid: 900, low: 550 } const BITRATE_PROFILE_MULTIPLIER = { save: 0.78, balanced: 1, clarity: 1.2 } function effectivePublishQualityKey() { try { const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY) if (v && QUALITY_MEDIA[v]) return v } catch (_) {} return 'source' } function targetVideoMaxBitrateBps(publishKey, bitrateProfile = 'balanced') { const kbps = QUALITY_VIDEO_MAX_KBPS[publishKey] || QUALITY_VIDEO_MAX_KBPS.source const m = BITRATE_PROFILE_MULTIPLIER[bitrateProfile] || BITRATE_PROFILE_MULTIPLIER.balanced return Math.max(220, Math.round(kbps * m)) * 1000 } async function applyVideoSenderPolicy(sender, publishKey, bitrateProfile) { if (!sender) return try { const p = sender.getParameters ? sender.getParameters() : null if (!p) return if (!p.encodings || !p.encodings.length) p.encodings = [{}] p.degradationPreference = 'maintain-framerate' p.encodings[0].maxBitrate = targetVideoMaxBitrateBps(publishKey, bitrateProfile) // 保留一定冗余,弱网抖动时更稳,避免一路拉满 p.encodings[0].maxFramerate = publishKey === 'low' ? 20 : publishKey === 'mid' ? 24 : 30 await sender.setParameters(p) } catch (_) {} } 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 const CANVAS_W = 1280 const CANVAS_H = 720 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 clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) } /** @param {() => { nx?: number, ny?: number, nw?: number, nh?: number } | null | undefined} getPipRect */ function readPipRect(getPipRect) { const d = typeof getPipRect === 'function' ? getPipRect() : null const nw = clamp(Number(d?.nw) || 0.24, 0.08, 0.55) const nh = clamp(Number(d?.nh) || 0.24, 0.08, 0.55) const defNx = 1 - nw - 10 / CANVAS_W const defNy = 1 - nh - 10 / CANVAS_H const nx = clamp(Number(d?.nx) || defNx, 0, 1 - nw) const ny = clamp(Number(d?.ny) || defNy, 0, 1 - nh) return { nx, ny, nw, nh } } 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) || '无法打开摄像头' } export function startPublishing(opts = {}) { const { token = '', captureMode: initialMode = 'camera', videoDeviceId: initialDeviceId = '', bitrateProfile = 'balanced', onStatus = () => {}, onLocalStream = () => {}, onActiveModeChange = () => {}, getPipRect = null } = opts if (!token) { onStatus('未登录,无法开播') return { stop: () => {}, switchMode: async () => {} } } const publishKey = effectivePublishQualityKey() const wsUrl = liveWsURLPublish(token) let activeMode = initialMode let deviceIdState = initialDeviceId let closedByLocal = false let stream = null let ws = null let pc = null let reconnectTimer = null let reconnectAttempt = 0 let wsGen = 0 let reconnectStopped = false let switchBusy = false let rafId = null let vScreen = null let vCam = null let canvasEl = null let screenShareTrack = null function teardownComposite() { if (rafId != null) { cancelAnimationFrame(rafId) rafId = null } if (vScreen) { try { const so = vScreen.srcObject if (so) so.getTracks().forEach((t) => t.stop()) } catch (_) {} try { vScreen.srcObject = null } catch (_) {} vScreen = null } if (vCam) { try { const so = vCam.srcObject if (so) so.getTracks().forEach((t) => t.stop()) } catch (_) {} try { vCam.srcObject = null } catch (_) {} vCam = null } canvasEl = null screenShareTrack = null } function clearReconnectTimer() { if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } } const send = (o) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)) } async function acquireDisplayStream() { 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 sTr = display.getVideoTracks()[0] if (!sTr) { display.getTracks().forEach((t) => t.stop()) throw new Error('未获得屏幕画面') } screenShareTrack = sTr sTr.addEventListener('ended', () => { if (!closedByLocal) { onStatus('屏幕共享已结束') stop() } }) return display } async function buildPublishStream() { teardownComposite() if (activeMode === 'screen_only') { const display = await acquireDisplayStream() const sTr = display.getVideoTracks()[0] let micStream try { micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) } catch (e) { sTr.stop() display.getTracks().forEach((t) => t.stop()) throw e } const previewVid = new MediaStream([sTr]) try { sTr.contentHint = 'detail' } catch (_) {} onLocalStream({ layout: 'screen_only', main: previewVid }) return new MediaStream([sTr, ...micStream.getAudioTracks()]) } if (activeMode === 'screen_pip') { const display = await acquireDisplayStream() const sTr = display.getVideoTracks()[0] let cam try { cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState)) } catch (e) { sTr.stop() display.getTracks().forEach((t) => t.stop()) throw e } vScreen = document.createElement('video') vCam = document.createElement('video') vScreen.muted = true vCam.muted = true vScreen.playsInline = true vCam.playsInline = true vScreen.srcObject = display vCam.srcObject = cam await vScreen.play().catch(() => {}) await vCam.play().catch(() => {}) canvasEl = document.createElement('canvas') canvasEl.width = CANVAS_W canvasEl.height = CANVAS_H const ctx = canvasEl.getContext('2d') function tick() { if (closedByLocal || !canvasEl) return ctx.fillStyle = '#000' ctx.fillRect(0, 0, CANVAS_W, CANVAS_H) if (vScreen && vScreen.readyState >= 2) { try { ctx.drawImage(vScreen, 0, 0, CANVAS_W, CANVAS_H) } catch (_) {} } if (vCam && vCam.readyState >= 2) { const { nx, ny, nw, nh } = readPipRect(getPipRect) const pw = Math.round(CANVAS_W * nw) const ph = Math.round(CANVAS_H * nh) const px = Math.round(CANVAS_W * nx) const py = Math.round(CANVAS_H * ny) ctx.strokeStyle = 'rgba(64,158,255,0.9)' ctx.lineWidth = 3 ctx.strokeRect(px, py, pw, ph) try { ctx.drawImage(vCam, px, py, pw, ph) } catch (_) {} } rafId = requestAnimationFrame(tick) } tick() const cap = canvasEl.captureStream(30) const outV = cap.getVideoTracks()[0] if (!outV) { teardownComposite() sTr.stop() cam.getTracks().forEach((t) => t.stop()) throw new Error('画布采集失败') } try { outV.contentHint = 'detail' } catch (_) {} const mic = cam.getAudioTracks() const publish = new MediaStream([outV, ...mic]) onLocalStream({ layout: 'screen_pip', screen: display, cam: new MediaStream([cam.getVideoTracks()[0]]) }) return publish } const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState)) try { const camV = s.getVideoTracks()[0] if (camV) camV.contentHint = 'motion' } catch (_) {} onLocalStream({ layout: 'camera', main: s }) 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 (_) {} }) teardownComposite() try { stream = await buildPublishStream() } 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) }) await applyVideoSenderPolicy( pc.getSenders().find((s) => s.track?.kind === 'video'), publishKey, bitrateProfile ) const offer = await pc.createOffer() await pc.setLocalDescription(offer) send({ type: 'offer', sdp: offer.sdp }) onStatus('协商中…') } async function switchMode(mode, camDeviceId) { if (closedByLocal || switchBusy || !pc || pc.signalingState === 'closed') return switchBusy = true if (typeof camDeviceId === 'string') deviceIdState = camDeviceId const switchingTo = mode activeMode = mode onStatus('切换画面中…') try { stream?.getTracks().forEach((t) => { try { t.stop() } catch (_) {} }) stream = null teardownComposite() try { stream = await buildPublishStream() } catch (e1) { if (switchingTo === 'screen_pip' || switchingTo === 'screen_only') { activeMode = 'camera' try { onActiveModeChange('camera') } catch (_) {} onStatus( e1?.message ? `${e1.message},已切回仅摄像头` : '屏幕共享未就绪,已切回仅摄像头' ) stream = await buildPublishStream() } else { throw e1 } } const vT = stream.getVideoTracks()[0] const aT = stream.getAudioTracks()[0] const vSender = pc.getSenders().find((s) => s.track?.kind === 'video') const aSender = pc.getSenders().find((s) => s.track?.kind === 'audio') if (vSender && vT) { await vSender.replaceTrack(vT) } else if (vT) { pc.addTrack(vT, stream) } if (aSender && aT) { await aSender.replaceTrack(aT) } else if (aT) { pc.addTrack(aT, stream) } await applyVideoSenderPolicy( pc.getSenders().find((s) => s.track?.kind === 'video'), publishKey, bitrateProfile ) const offer = await pc.createOffer({ iceRestart: false }) await pc.setLocalDescription(offer) send({ type: 'offer', sdp: offer.sdp }) onStatus('已切换,协商中…') } catch (e) { onStatus(e?.message || humanizeGetUserMediaError(e)) } finally { switchBusy = false } } 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 或未启动。修复后刷新本页再开播。` ) 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})`) } } 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() teardownComposite() 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, switchMode: (mode, camDeviceId) => switchMode(mode, camDeviceId) } }