142 lines
4.8 KiB
JavaScript
142 lines
4.8 KiB
JavaScript
/**
|
||
* 管理后台 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 }
|
||
}
|