Files
web/admin/src/utils/liveWebRTC.js
whm fe8d5a34cc 直播后台:新增按 IP 发言管控与在线会话视图。
支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。

Made-with: Cursor
2026-03-26 17:57:50 +08:00

624 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 管理后台 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)
}
}