376 lines
10 KiB
JavaScript
376 lines
10 KiB
JavaScript
/**
|
||
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=)
|
||
* 画质:官网 /live 写入 localStorage(yh_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 }
|
||
}
|