624 lines
17 KiB
JavaScript
624 lines
17 KiB
JavaScript
/**
|
||
* 管理后台 WebRTC 开播(观众端始终单路视频)
|
||
* - camera:仅摄像头
|
||
* - screen_only:仅共享屏幕 + 麦克风
|
||
* - screen_pip:屏幕与摄像头 Canvas 合成;小窗位置由 getPipRect() 归一化矩形 (nx,ny,nw,nh) 决定,与预览拖动一致
|
||
* - 直播中可 switchMode 切换,无需结束直播
|
||
*/
|
||
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
|
||
}
|
||
}
|
||
|
||
// 码率上限(kbps): 在实时性与并发之间取平衡
|
||
const QUALITY_VIDEO_MAX_KBPS = {
|
||
source: 1800,
|
||
high: 1400,
|
||
mid: 900,
|
||
low: 550
|
||
}
|
||
|
||
const BITRATE_PROFILE_MULTIPLIER = {
|
||
save: 0.78,
|
||
balanced: 1,
|
||
clarity: 1.2
|
||
}
|
||
|
||
function effectivePublishQualityKey() {
|
||
try {
|
||
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
|
||
if (v && QUALITY_MEDIA[v]) return v
|
||
} catch (_) {}
|
||
return 'source'
|
||
}
|
||
|
||
function targetVideoMaxBitrateBps(publishKey, bitrateProfile = 'balanced') {
|
||
const kbps = QUALITY_VIDEO_MAX_KBPS[publishKey] || QUALITY_VIDEO_MAX_KBPS.source
|
||
const m = BITRATE_PROFILE_MULTIPLIER[bitrateProfile] || BITRATE_PROFILE_MULTIPLIER.balanced
|
||
return Math.max(220, Math.round(kbps * m)) * 1000
|
||
}
|
||
|
||
async function applyVideoSenderPolicy(sender, publishKey, bitrateProfile) {
|
||
if (!sender) return
|
||
try {
|
||
const p = sender.getParameters ? sender.getParameters() : null
|
||
if (!p) return
|
||
if (!p.encodings || !p.encodings.length) p.encodings = [{}]
|
||
p.degradationPreference = 'maintain-framerate'
|
||
p.encodings[0].maxBitrate = targetVideoMaxBitrateBps(publishKey, bitrateProfile)
|
||
// 保留一定冗余,弱网抖动时更稳,避免一路拉满
|
||
p.encodings[0].maxFramerate =
|
||
publishKey === 'low' ? 20 : publishKey === 'mid' ? 24 : 30
|
||
await sender.setParameters(p)
|
||
} catch (_) {}
|
||
}
|
||
|
||
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
|
||
|
||
const CANVAS_W = 1280
|
||
const CANVAS_H = 720
|
||
|
||
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 clamp(v, lo, hi) {
|
||
return Math.min(hi, Math.max(lo, v))
|
||
}
|
||
|
||
/** @param {() => { nx?: number, ny?: number, nw?: number, nh?: number } | null | undefined} getPipRect */
|
||
function readPipRect(getPipRect) {
|
||
const d = typeof getPipRect === 'function' ? getPipRect() : null
|
||
const nw = clamp(Number(d?.nw) || 0.24, 0.08, 0.55)
|
||
const nh = clamp(Number(d?.nh) || 0.24, 0.08, 0.55)
|
||
const defNx = 1 - nw - 10 / CANVAS_W
|
||
const defNy = 1 - nh - 10 / CANVAS_H
|
||
const nx = clamp(Number(d?.nx) || defNx, 0, 1 - nw)
|
||
const ny = clamp(Number(d?.ny) || defNy, 0, 1 - nh)
|
||
return { nx, ny, nw, nh }
|
||
}
|
||
|
||
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) || '无法打开摄像头'
|
||
}
|
||
|
||
export function startPublishing(opts = {}) {
|
||
const {
|
||
token = '',
|
||
captureMode: initialMode = 'camera',
|
||
videoDeviceId: initialDeviceId = '',
|
||
bitrateProfile = 'balanced',
|
||
onStatus = () => {},
|
||
onLocalStream = () => {},
|
||
onActiveModeChange = () => {},
|
||
getPipRect = null
|
||
} = opts
|
||
|
||
if (!token) {
|
||
onStatus('未登录,无法开播')
|
||
return { stop: () => {}, switchMode: async () => {} }
|
||
}
|
||
|
||
const publishKey = effectivePublishQualityKey()
|
||
const wsUrl = liveWsURLPublish(token)
|
||
|
||
let activeMode = initialMode
|
||
let deviceIdState = initialDeviceId
|
||
|
||
let closedByLocal = false
|
||
let stream = null
|
||
let ws = null
|
||
let pc = null
|
||
let reconnectTimer = null
|
||
let reconnectAttempt = 0
|
||
let wsGen = 0
|
||
let reconnectStopped = false
|
||
let switchBusy = false
|
||
|
||
let rafId = null
|
||
let vScreen = null
|
||
let vCam = null
|
||
let canvasEl = null
|
||
let screenShareTrack = null
|
||
|
||
function teardownComposite() {
|
||
if (rafId != null) {
|
||
cancelAnimationFrame(rafId)
|
||
rafId = null
|
||
}
|
||
if (vScreen) {
|
||
try {
|
||
const so = vScreen.srcObject
|
||
if (so) so.getTracks().forEach((t) => t.stop())
|
||
} catch (_) {}
|
||
try {
|
||
vScreen.srcObject = null
|
||
} catch (_) {}
|
||
vScreen = null
|
||
}
|
||
if (vCam) {
|
||
try {
|
||
const so = vCam.srcObject
|
||
if (so) so.getTracks().forEach((t) => t.stop())
|
||
} catch (_) {}
|
||
try {
|
||
vCam.srcObject = null
|
||
} catch (_) {}
|
||
vCam = null
|
||
}
|
||
canvasEl = null
|
||
screenShareTrack = null
|
||
}
|
||
|
||
function clearReconnectTimer() {
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer)
|
||
reconnectTimer = null
|
||
}
|
||
}
|
||
|
||
const send = (o) => {
|
||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
|
||
}
|
||
|
||
async function acquireDisplayStream() {
|
||
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 sTr = display.getVideoTracks()[0]
|
||
if (!sTr) {
|
||
display.getTracks().forEach((t) => t.stop())
|
||
throw new Error('未获得屏幕画面')
|
||
}
|
||
screenShareTrack = sTr
|
||
sTr.addEventListener('ended', () => {
|
||
if (!closedByLocal) {
|
||
onStatus('屏幕共享已结束')
|
||
stop()
|
||
}
|
||
})
|
||
return display
|
||
}
|
||
|
||
async function buildPublishStream() {
|
||
teardownComposite()
|
||
if (activeMode === 'screen_only') {
|
||
const display = await acquireDisplayStream()
|
||
const sTr = display.getVideoTracks()[0]
|
||
let micStream
|
||
try {
|
||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||
} catch (e) {
|
||
sTr.stop()
|
||
display.getTracks().forEach((t) => t.stop())
|
||
throw e
|
||
}
|
||
const previewVid = new MediaStream([sTr])
|
||
try {
|
||
sTr.contentHint = 'detail'
|
||
} catch (_) {}
|
||
onLocalStream({ layout: 'screen_only', main: previewVid })
|
||
return new MediaStream([sTr, ...micStream.getAudioTracks()])
|
||
}
|
||
if (activeMode === 'screen_pip') {
|
||
const display = await acquireDisplayStream()
|
||
const sTr = display.getVideoTracks()[0]
|
||
let cam
|
||
try {
|
||
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
|
||
} catch (e) {
|
||
sTr.stop()
|
||
display.getTracks().forEach((t) => t.stop())
|
||
throw e
|
||
}
|
||
vScreen = document.createElement('video')
|
||
vCam = document.createElement('video')
|
||
vScreen.muted = true
|
||
vCam.muted = true
|
||
vScreen.playsInline = true
|
||
vCam.playsInline = true
|
||
vScreen.srcObject = display
|
||
vCam.srcObject = cam
|
||
await vScreen.play().catch(() => {})
|
||
await vCam.play().catch(() => {})
|
||
|
||
canvasEl = document.createElement('canvas')
|
||
canvasEl.width = CANVAS_W
|
||
canvasEl.height = CANVAS_H
|
||
const ctx = canvasEl.getContext('2d')
|
||
|
||
function tick() {
|
||
if (closedByLocal || !canvasEl) return
|
||
ctx.fillStyle = '#000'
|
||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
|
||
if (vScreen && vScreen.readyState >= 2) {
|
||
try {
|
||
ctx.drawImage(vScreen, 0, 0, CANVAS_W, CANVAS_H)
|
||
} catch (_) {}
|
||
}
|
||
if (vCam && vCam.readyState >= 2) {
|
||
const { nx, ny, nw, nh } = readPipRect(getPipRect)
|
||
const pw = Math.round(CANVAS_W * nw)
|
||
const ph = Math.round(CANVAS_H * nh)
|
||
const px = Math.round(CANVAS_W * nx)
|
||
const py = Math.round(CANVAS_H * ny)
|
||
ctx.strokeStyle = 'rgba(64,158,255,0.9)'
|
||
ctx.lineWidth = 3
|
||
ctx.strokeRect(px, py, pw, ph)
|
||
try {
|
||
ctx.drawImage(vCam, px, py, pw, ph)
|
||
} catch (_) {}
|
||
}
|
||
rafId = requestAnimationFrame(tick)
|
||
}
|
||
tick()
|
||
|
||
const cap = canvasEl.captureStream(30)
|
||
const outV = cap.getVideoTracks()[0]
|
||
if (!outV) {
|
||
teardownComposite()
|
||
sTr.stop()
|
||
cam.getTracks().forEach((t) => t.stop())
|
||
throw new Error('画布采集失败')
|
||
}
|
||
try {
|
||
outV.contentHint = 'detail'
|
||
} catch (_) {}
|
||
const mic = cam.getAudioTracks()
|
||
const publish = new MediaStream([outV, ...mic])
|
||
onLocalStream({
|
||
layout: 'screen_pip',
|
||
screen: display,
|
||
cam: new MediaStream([cam.getVideoTracks()[0]])
|
||
})
|
||
return publish
|
||
}
|
||
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
|
||
try {
|
||
const camV = s.getVideoTracks()[0]
|
||
if (camV) camV.contentHint = 'motion'
|
||
} catch (_) {}
|
||
onLocalStream({ layout: 'camera', main: s })
|
||
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 (_) {}
|
||
})
|
||
teardownComposite()
|
||
try {
|
||
stream = await buildPublishStream()
|
||
} 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)
|
||
})
|
||
await applyVideoSenderPolicy(
|
||
pc.getSenders().find((s) => s.track?.kind === 'video'),
|
||
publishKey,
|
||
bitrateProfile
|
||
)
|
||
const offer = await pc.createOffer()
|
||
await pc.setLocalDescription(offer)
|
||
send({ type: 'offer', sdp: offer.sdp })
|
||
onStatus('协商中…')
|
||
}
|
||
|
||
async function switchMode(mode, camDeviceId) {
|
||
if (closedByLocal || switchBusy || !pc || pc.signalingState === 'closed') return
|
||
switchBusy = true
|
||
if (typeof camDeviceId === 'string') deviceIdState = camDeviceId
|
||
const switchingTo = mode
|
||
activeMode = mode
|
||
onStatus('切换画面中…')
|
||
try {
|
||
stream?.getTracks().forEach((t) => {
|
||
try {
|
||
t.stop()
|
||
} catch (_) {}
|
||
})
|
||
stream = null
|
||
teardownComposite()
|
||
try {
|
||
stream = await buildPublishStream()
|
||
} catch (e1) {
|
||
if (switchingTo === 'screen_pip' || switchingTo === 'screen_only') {
|
||
activeMode = 'camera'
|
||
try {
|
||
onActiveModeChange('camera')
|
||
} catch (_) {}
|
||
onStatus(
|
||
e1?.message ? `${e1.message},已切回仅摄像头` : '屏幕共享未就绪,已切回仅摄像头'
|
||
)
|
||
stream = await buildPublishStream()
|
||
} else {
|
||
throw e1
|
||
}
|
||
}
|
||
const vT = stream.getVideoTracks()[0]
|
||
const aT = stream.getAudioTracks()[0]
|
||
const vSender = pc.getSenders().find((s) => s.track?.kind === 'video')
|
||
const aSender = pc.getSenders().find((s) => s.track?.kind === 'audio')
|
||
if (vSender && vT) {
|
||
await vSender.replaceTrack(vT)
|
||
} else if (vT) {
|
||
pc.addTrack(vT, stream)
|
||
}
|
||
if (aSender && aT) {
|
||
await aSender.replaceTrack(aT)
|
||
} else if (aT) {
|
||
pc.addTrack(aT, stream)
|
||
}
|
||
await applyVideoSenderPolicy(
|
||
pc.getSenders().find((s) => s.track?.kind === 'video'),
|
||
publishKey,
|
||
bitrateProfile
|
||
)
|
||
const offer = await pc.createOffer({ iceRestart: false })
|
||
await pc.setLocalDescription(offer)
|
||
send({ type: 'offer', sdp: offer.sdp })
|
||
onStatus('已切换,协商中…')
|
||
} catch (e) {
|
||
onStatus(e?.message || humanizeGetUserMediaError(e))
|
||
} finally {
|
||
switchBusy = false
|
||
}
|
||
}
|
||
|
||
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 或未启动。修复后刷新本页再开播。`
|
||
)
|
||
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})`)
|
||
}
|
||
} 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()
|
||
teardownComposite()
|
||
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,
|
||
switchMode: (mode, camDeviceId) => switchMode(mode, camDeviceId)
|
||
}
|
||
}
|