直播:三模式采集、Canvas 单轨与可拖动小窗;观众端单路视频
Made-with: Cursor
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=)
|
||||
* 画质:官网 /live 写入 localStorage(yh_live_capture_quality)
|
||||
* 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream)
|
||||
* 管理后台 WebRTC 开播(观众端始终单路视频)
|
||||
* - camera:仅摄像头
|
||||
* - screen_only:仅共享屏幕 + 麦克风
|
||||
* - screen_pip:屏幕与摄像头 Canvas 合成;小窗位置由 getPipRect() 归一化矩形 (nx,ny,nw,nh) 决定,与预览拖动一致
|
||||
* - 直播中可 switchMode 切换,无需结束直播
|
||||
*/
|
||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||
|
||||
@@ -59,6 +61,9 @@ 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`
|
||||
@@ -80,6 +85,22 @@ function buildCameraConstraints(publishKey, videoDeviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -106,31 +127,28 @@ function humanizeGetUserMediaError(err) {
|
||||
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 = '',
|
||||
captureMode: initialMode = 'camera',
|
||||
videoDeviceId: initialDeviceId = '',
|
||||
onStatus = () => {},
|
||||
onLocalStream = () => {}
|
||||
onLocalStream = () => {},
|
||||
onActiveModeChange = () => {},
|
||||
getPipRect = null
|
||||
} = opts
|
||||
|
||||
if (!token) {
|
||||
onStatus('未登录,无法开播')
|
||||
return { stop: () => {} }
|
||||
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
|
||||
@@ -139,6 +157,42 @@ export function startPublishing(opts = {}) {
|
||||
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) {
|
||||
@@ -151,44 +205,121 @@ 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()
|
||||
}
|
||||
async function acquireDisplayStream() {
|
||||
let display
|
||||
try {
|
||||
display = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: false
|
||||
})
|
||||
let cam
|
||||
} 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 {
|
||||
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
} catch (e) {
|
||||
screenVideo.stop()
|
||||
sTr.stop()
|
||||
display.getTracks().forEach((t) => t.stop())
|
||||
throw e
|
||||
}
|
||||
const camVideo = cam.getVideoTracks()[0]
|
||||
const previewVid = new MediaStream([sTr])
|
||||
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('画布采集失败')
|
||||
}
|
||||
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 })
|
||||
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, videoDeviceId))
|
||||
onLocalStream({ main: s, pip: null })
|
||||
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
|
||||
onLocalStream({ layout: 'camera', main: s })
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -203,8 +334,9 @@ export function startPublishing(opts = {}) {
|
||||
t.stop()
|
||||
} catch (_) {}
|
||||
})
|
||||
teardownComposite()
|
||||
try {
|
||||
stream = await acquireMedia()
|
||||
stream = await buildPublishStream()
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e && typeof e.message === 'string' && e.message
|
||||
@@ -233,6 +365,62 @@ export function startPublishing(opts = {}) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
@@ -269,7 +457,7 @@ export function startPublishing(opts = {}) {
|
||||
if (reconnectAttempt > MAX_SIGNAL_RECONNECT) {
|
||||
reconnectStopped = true
|
||||
onStatus(
|
||||
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 返回 502(进程未启动/崩溃)或 Nginx 未正确反代到 Go。请检查服务与 \`/api/web/live/ws\` 的 WebSocket 配置,修复后刷新本页再点「开始直播」。`
|
||||
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 502 或未启动。修复后刷新本页再开播。`
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -281,10 +469,10 @@ export function startPublishing(opts = {}) {
|
||||
try {
|
||||
const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' })
|
||||
if (!r.ok) {
|
||||
onStatus(`API 不可用(HTTP ${r.status}),多为网关 502,推迟重试…`)
|
||||
onStatus(`API 不可用(HTTP ${r.status})`)
|
||||
}
|
||||
} catch (_) {
|
||||
onStatus('无法访问健康检查接口,请确认网络与域名。')
|
||||
onStatus('无法访问健康检查接口')
|
||||
}
|
||||
if (closedByLocal || reconnectStopped) return
|
||||
openSignalingSocket()
|
||||
@@ -349,6 +537,7 @@ export function startPublishing(opts = {}) {
|
||||
reconnectStopped = true
|
||||
wsGen += 1
|
||||
clearReconnectTimer()
|
||||
teardownComposite()
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close()
|
||||
@@ -371,5 +560,8 @@ export function startPublishing(opts = {}) {
|
||||
|
||||
openSignalingSocket()
|
||||
|
||||
return { stop }
|
||||
return {
|
||||
stop,
|
||||
switchMode: (mode, camDeviceId) => switchMode(mode, camDeviceId)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user