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

376 lines
10 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 开播(/api/web/live/ws?role=publish&token=
* 画质:官网 /live 写入 localStorageyh_live_capture_quality
* 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream
*/
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
const LIVE_CAPTURE_QUALITY_STORAGE_KEY = 'yh_live_capture_quality'
const QUALITY_MEDIA = {
source: { video: true, audio: true },
high: {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: true
},
mid: {
video: {
width: { ideal: 854 },
height: { ideal: 480 },
frameRate: { ideal: 24 }
},
audio: true
},
low: {
video: {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 20 }
},
audio: true
}
}
function effectivePublishQualityKey() {
try {
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
if (v && QUALITY_MEDIA[v]) return v
} catch (_) {}
return 'source'
}
function liveWsURLPublish(token) {
const q = effectivePublishQualityKey()
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
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' }]
const MAX_SIGNAL_RECONNECT = 15
function healthCheckUrl() {
if (apiBase) return `${apiBase}/api/health`
if (typeof window !== 'undefined') return `${window.location.origin}/api/health`
return '/api/health'
}
function buildCameraConstraints(publishKey, videoDeviceId) {
const preset = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
const dev = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : {}
if (preset.video === true) {
return {
audio: true,
video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true
}
}
return {
audio: true,
video: { ...preset.video, ...dev }
}
}
function humanizeGetUserMediaError(err) {
const name = err && err.name
const raw = ((err && err.message) || '').toLowerCase()
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
return '已拒绝摄像头或麦克风权限。'
}
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 '摄像头被占用或不可用,请关闭其它使用相机的程序后重试。'
}
if (name === 'OverconstrainedError') {
return '当前摄像头不满足画质约束,请在官网「直播」页改为「原画」或换摄像头。'
}
if (name === 'AbortError') {
return '采集被中断,请重试。'
}
return (err && err.message) || '无法打开摄像头'
}
/**
* @param {object} opts
* @param {string} opts.token
* @param {'camera'|'screen_pip'} [opts.captureMode]
* @param {string} [opts.videoDeviceId] videoinput deviceId空则默认设备
* @param {(s: string) => void} [opts.onStatus]
* @param {(p: { main: MediaStream, pip: MediaStream|null }) => void} [opts.onLocalStream]
*/
export function startPublishing(opts = {}) {
const {
token = '',
captureMode = 'camera',
videoDeviceId = '',
onStatus = () => {},
onLocalStream = () => {}
} = opts
if (!token) {
onStatus('未登录,无法开播')
return { stop: () => {} }
}
const publishKey = effectivePublishQualityKey()
const wsUrl = liveWsURLPublish(token)
let closedByLocal = false
let stream = null
let ws = null
let pc = null
let reconnectTimer = null
let reconnectAttempt = 0
let wsGen = 0
let reconnectStopped = false
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const send = (o) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
}
async function acquireMedia() {
if (captureMode === 'screen_pip') {
let display
try {
display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
} catch (e) {
const name = e && e.name
throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
}
const screenVideo = display.getVideoTracks()[0]
if (!screenVideo) {
display.getTracks().forEach((t) => t.stop())
throw new Error('未获得屏幕画面')
}
screenVideo.addEventListener('ended', () => {
if (!closedByLocal) {
onStatus('屏幕共享已结束')
stop()
}
})
let cam
try {
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
} catch (e) {
screenVideo.stop()
display.getTracks().forEach((t) => t.stop())
throw e
}
const camVideo = cam.getVideoTracks()[0]
const mic = cam.getAudioTracks()
const publish = new MediaStream([screenVideo, camVideo, ...mic])
const mainPrev = new MediaStream([screenVideo])
const pipPrev = new MediaStream([camVideo])
onLocalStream({ main: mainPrev, pip: pipPrev })
return publish
}
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
onLocalStream({ main: s, pip: null })
return s
}
async function ensureStreamAndAttach() {
const needNew =
!stream ||
!stream.getTracks().length ||
stream.getTracks().some((t) => t.readyState !== 'live')
if (needNew) {
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
try {
stream = await acquireMedia()
} catch (e) {
const msg =
e && typeof e.message === 'string' && e.message
? e.message
: humanizeGetUserMediaError(e)
onStatus(msg)
throw e
}
}
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 && pc) {
pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }).then(
() => {
reconnectAttempt = 0
clearReconnectTimer()
onStatus('直播中')
},
(e) => {
onStatus(e.message || '协商失败')
}
)
}
if (msg.type === 'ice' && msg.candidate && pc) {
try {
pc.addIceCandidate(msg.candidate)
} catch (_) {}
}
if (msg.type === 'error') {
onStatus(msg.message || '服务端错误')
}
}
function scheduleReconnect() {
if (closedByLocal || reconnectStopped) return
clearReconnectTimer()
reconnectAttempt += 1
if (reconnectAttempt > MAX_SIGNAL_RECONNECT) {
reconnectStopped = true
onStatus(
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 返回 502进程未启动/崩溃)或 Nginx 未正确反代到 Go。请检查服务与 \`/api/web/live/ws\` 的 WebSocket 配置,修复后刷新本页再点「开始直播」。`
)
return
}
const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt - 1), 28000)
onStatus(`信令断开,约 ${Math.round(delay / 1000)} 秒后重试(${reconnectAttempt}/${MAX_SIGNAL_RECONNECT})…`)
reconnectTimer = window.setTimeout(async () => {
reconnectTimer = null
if (closedByLocal || reconnectStopped) return
try {
const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' })
if (!r.ok) {
onStatus(`API 不可用HTTP ${r.status}),多为网关 502推迟重试…`)
}
} catch (_) {
onStatus('无法访问健康检查接口,请确认网络与域名。')
}
if (closedByLocal || reconnectStopped) return
openSignalingSocket()
}, delay)
}
function openSignalingSocket() {
if (closedByLocal || reconnectStopped) 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) {
if (!closedByLocal) {
onStatus(err?.message || humanizeGetUserMediaError(err))
stop()
}
}
}
ws.onmessage = onSocketMessage
ws.onerror = () => {
if (!closedByLocal) onStatus('信令异常')
}
ws.onclose = () => {
if (myGen !== wsGen) return
ws = null
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
if (closedByLocal) return
scheduleReconnect()
}
}
function stop() {
closedByLocal = true
reconnectStopped = true
wsGen += 1
clearReconnectTimer()
if (ws) {
try {
ws.close()
} catch (_) {}
ws = null
}
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
stream = null
}
openSignalingSocket()
return { stop }
}