直播:开播信令断线自动重连;弹幕代次防误重连、断线排队发送
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
|
||||
* 画质由官网 /live 写入 localStorage(yh_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('信令已断开:服务端关闭连接或网络中断。若刚能连上即断,请查服务端日志或配置 TURN(LIVE_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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user