直播:后台 JWT 推流、前台画中画;WebRTC 服务与 Nginx WebSocket 代理
Made-with: Cursor
This commit is contained in:
106
admin/src/utils/liveWebRTC.js
Normal file
106
admin/src/utils/liveWebRTC.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 管理后台 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' }]
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
||||
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 {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user' },
|
||||
audio: false
|
||||
})
|
||||
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(err.message || '无法打开摄像头')
|
||||
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 = () => onStatus('信令连接失败(请确认已登录且 Nginx 已配置 WebSocket)')
|
||||
ws.onclose = () => onStatus('信令已断开')
|
||||
|
||||
function stop() {
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user