Files
web/admin/src/utils/liveWebRTC.js

142 lines
4.8 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 开播(需登录 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('信令已断开:服务端关闭连接或网络中断。若刚能连上即断,请查服务端日志或配置 TURNLIVE_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 }
}