直播:开播信令断线自动重连;弹幕代次防误重连、断线排队发送

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 10:38:18 +08:00
parent 8d730a2a75
commit 9329151976
2 changed files with 205 additions and 70 deletions

View File

@@ -1,6 +1,7 @@
/**
* 管理后台 WebRTC 开播(需登录 token与 /api/web/live/ws?role=publish&token= 一致)
* 画质由官网 /live 写入 localStorageyh_live_capture_quality同浏览器开播时生效默认原画约束最少。
* 信令异常断开时自动重连(复用已采集的 MediaStream不重复弹权限
*/
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
@@ -97,54 +98,83 @@ export function startPublishing(opts = {}) {
const publishKey = effectivePublishQualityKey()
const wsUrl = liveWsURLPublish(token)
const ws = new WebSocket(wsUrl)
const pc = new RTCPeerConnection({ iceServers: defaultIce })
let stream = null
let closedByLocal = false
let stream = null
let ws = null
let pc = null
let reconnectTimer = null
let reconnectAttempt = 0
/** 先于关闭旧 WebSocket 递增,避免旧 onclose 误触发重连 */
let wsGen = 0
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 {
const cons = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
stream = await navigator.mediaDevices.getUserMedia(cons)
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()
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
ws.onmessage = async (ev) => {
const send = (o) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
}
async function ensureStreamAndAttach() {
const cons = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
const needNew =
!stream ||
!stream.getTracks().length ||
stream.getTracks().some((t) => t.readyState !== 'live')
if (needNew) {
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
stream = await navigator.mediaDevices.getUserMedia(cons)
onLocalStream(stream)
}
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)
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
onStatus('已发起推流协商,等待服务端应答…')
}
function onSocketMessage(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 === '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) {
if (msg.type === 'ice' && msg.candidate && pc) {
try {
await pc.addIceCandidate(msg.candidate)
pc.addIceCandidate(msg.candidate)
} catch (_) {}
}
if (msg.type === 'error') {
@@ -152,29 +182,97 @@ export function startPublishing(opts = {}) {
}
}
ws.onerror = () => {
if (!closedByLocal) {
onStatus('信令连接失败(请确认已登录且 Nginx 已配置 WebSocket')
function scheduleReconnect() {
if (closedByLocal) return
clearReconnectTimer()
const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt), 28000)
reconnectAttempt += 1
onStatus(`信令断开,${Math.round(delay / 1000)} 秒后自动重连(第 ${reconnectAttempt} 次)…`)
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null
if (closedByLocal) return
openSignalingSocket()
}, delay)
}
function openSignalingSocket() {
if (closedByLocal) 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) {
onStatus(humanizeGetUserMediaError(err))
stop()
}
}
ws.onmessage = onSocketMessage
ws.onerror = () => {
if (!closedByLocal) {
onStatus('信令 WebSocket 异常(请查 Nginx Upgrade 与网关)')
}
}
ws.onclose = () => {
if (myGen !== wsGen) return
ws = null
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
if (closedByLocal) return
scheduleReconnect()
}
}
ws.onclose = () => {
if (closedByLocal) return
onStatus('信令已断开:服务端关闭连接或网络中断。若刚能连上即断,请查服务端日志或配置 TURNLIVE_ICE_SERVERS。')
}
openSignalingSocket()
function stop() {
closedByLocal = true
try {
ws.close()
} catch (_) {}
pc.getSenders().forEach((s) => {
wsGen += 1
clearReconnectTimer()
if (ws) {
try {
s.track?.stop()
ws.close()
} catch (_) {}
ws = null
}
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
pc.close()
stream?.getTracks().forEach((t) => t.stop())
stream = null
}
return { pc, ws, stop }
return { stop }
}