开播:摄像头选择+屏幕共享小窗;观众双路视频;去后台长提示与精简状态
Made-with: Cursor
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
|
||||
* 画质由官网 /live 写入 localStorage(yh_live_capture_quality),同浏览器开播时生效;默认原画约束最少。
|
||||
* 信令异常断开时自动重连(复用已采集的 MediaStream,不重复弹权限)。
|
||||
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=)
|
||||
* 画质:官网 /live 写入 localStorage(yh_live_capture_quality)
|
||||
* 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream)
|
||||
*/
|
||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||
|
||||
@@ -57,14 +57,29 @@ function liveWsURLPublish(token) {
|
||||
|
||||
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
|
||||
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 '已拒绝摄像头或麦克风权限:在浏览器地址栏左侧允许摄像头与麦克风,并确认本页为 HTTPS。'
|
||||
return '已拒绝摄像头或麦克风权限。'
|
||||
}
|
||||
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
|
||||
return '未检测到摄像头,请检查是否已接入设备或被系统禁用。'
|
||||
return '未检测到摄像头。'
|
||||
}
|
||||
if (
|
||||
name === 'NotReadableError' ||
|
||||
@@ -72,25 +87,34 @@ function humanizeGetUserMediaError(err) {
|
||||
raw.includes('failed to start video source') ||
|
||||
raw.includes('video source')
|
||||
) {
|
||||
return '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。'
|
||||
return '摄像头被占用或不可用,请关闭其它使用相机的程序后重试。'
|
||||
}
|
||||
if (name === 'OverconstrainedError') {
|
||||
return '摄像头不满足当前参数约束:请到官网「直播」页换一档画质(或选原画)后再开播。'
|
||||
return '当前摄像头不满足画质约束,请在官网「直播」页改为「原画」或换摄像头。'
|
||||
}
|
||||
if (name === 'AbortError') {
|
||||
return '打开摄像头被系统中断,请重试。'
|
||||
return '采集被中断,请重试。'
|
||||
}
|
||||
return (err && err.message) || '无法打开摄像头'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.token 管理员 JWT
|
||||
* @param {string} opts.token
|
||||
* @param {'camera'|'screen_pip'} [opts.captureMode]
|
||||
* @param {string} [opts.videoDeviceId] videoinput deviceId,空则默认设备
|
||||
* @param {(s: string) => void} [opts.onStatus]
|
||||
* @param {(stream: MediaStream) => void} [opts.onLocalStream]
|
||||
* @param {(p: { main: MediaStream, pip: MediaStream|null }) => void} [opts.onLocalStream]
|
||||
*/
|
||||
export function startPublishing(opts = {}) {
|
||||
const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
|
||||
const {
|
||||
token = '',
|
||||
captureMode = 'camera',
|
||||
videoDeviceId = '',
|
||||
onStatus = () => {},
|
||||
onLocalStream = () => {}
|
||||
} = opts
|
||||
|
||||
if (!token) {
|
||||
onStatus('未登录,无法开播')
|
||||
return { stop: () => {} }
|
||||
@@ -105,7 +129,6 @@ export function startPublishing(opts = {}) {
|
||||
let pc = null
|
||||
let reconnectTimer = null
|
||||
let reconnectAttempt = 0
|
||||
/** 先于关闭旧 WebSocket 递增,避免旧 onclose 误触发重连 */
|
||||
let wsGen = 0
|
||||
|
||||
function clearReconnectTimer() {
|
||||
@@ -119,8 +142,48 @@ export function startPublishing(opts = {}) {
|
||||
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 cons = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
|
||||
const needNew =
|
||||
!stream ||
|
||||
!stream.getTracks().length ||
|
||||
@@ -131,8 +194,16 @@ export function startPublishing(opts = {}) {
|
||||
t.stop()
|
||||
} catch (_) {}
|
||||
})
|
||||
stream = await navigator.mediaDevices.getUserMedia(cons)
|
||||
onLocalStream(stream)
|
||||
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 {
|
||||
@@ -150,7 +221,7 @@ export function startPublishing(opts = {}) {
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
send({ type: 'offer', sdp: offer.sdp })
|
||||
onStatus('已发起推流协商,等待服务端应答…')
|
||||
onStatus('协商中…')
|
||||
}
|
||||
|
||||
function onSocketMessage(ev) {
|
||||
@@ -165,10 +236,10 @@ export function startPublishing(opts = {}) {
|
||||
() => {
|
||||
reconnectAttempt = 0
|
||||
clearReconnectTimer()
|
||||
onStatus('直播中(信令断开会自动重连;结束请点「结束直播」)')
|
||||
onStatus('直播中')
|
||||
},
|
||||
(e) => {
|
||||
onStatus(e.message || '设置远端描述失败')
|
||||
onStatus(e.message || '协商失败')
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -187,7 +258,7 @@ export function startPublishing(opts = {}) {
|
||||
clearReconnectTimer()
|
||||
const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt), 28000)
|
||||
reconnectAttempt += 1
|
||||
onStatus(`信令断开,${Math.round(delay / 1000)} 秒后自动重连(第 ${reconnectAttempt} 次)…`)
|
||||
onStatus(`信令断开,${Math.round(delay / 1000)} 秒后重连(${reconnectAttempt})…`)
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (closedByLocal) return
|
||||
@@ -214,25 +285,25 @@ export function startPublishing(opts = {}) {
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
} catch (_) {
|
||||
onStatus('无法创建信令连接')
|
||||
onStatus('无法连接信令')
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
ws.onopen = async () => {
|
||||
if (closedByLocal || myGen !== wsGen) return
|
||||
onStatus('信令已连接,正在采集摄像头…')
|
||||
onStatus('采集中…')
|
||||
try {
|
||||
await ensureStreamAndAttach()
|
||||
} catch (err) {
|
||||
onStatus(humanizeGetUserMediaError(err))
|
||||
stop()
|
||||
if (!closedByLocal) {
|
||||
onStatus(err?.message || humanizeGetUserMediaError(err))
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
ws.onmessage = onSocketMessage
|
||||
ws.onerror = () => {
|
||||
if (!closedByLocal) {
|
||||
onStatus('信令 WebSocket 异常(请查 Nginx Upgrade 与网关)')
|
||||
}
|
||||
if (!closedByLocal) onStatus('信令异常')
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (myGen !== wsGen) return
|
||||
@@ -248,8 +319,6 @@ export function startPublishing(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
openSignalingSocket()
|
||||
|
||||
function stop() {
|
||||
closedByLocal = true
|
||||
wsGen += 1
|
||||
@@ -274,5 +343,7 @@ export function startPublishing(opts = {}) {
|
||||
stream = null
|
||||
}
|
||||
|
||||
openSignalingSocket()
|
||||
|
||||
return { stop }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user