diff --git a/admin/src/utils/liveWebRTC.js b/admin/src/utils/liveWebRTC.js index b44401d..aa81657 100644 --- a/admin/src/utils/liveWebRTC.js +++ b/admin/src/utils/liveWebRTC.js @@ -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) + } } diff --git a/admin/src/views/sites/LiveBroadcast.vue b/admin/src/views/sites/LiveBroadcast.vue index 1b300ae..c9ce215 100644 --- a/admin/src/views/sites/LiveBroadcast.vue +++ b/admin/src/views/sites/LiveBroadcast.vue @@ -5,12 +5,13 @@ 官网视频直播(WebRTC)
{{ status }}
-+ 直播中可切换三种模式;「屏幕+摄像头」下可拖动小窗,观众画面与预览一致(画布 16:9 + 铺满)。共享整屏时尽量勿把本管理页选进画面,以免套娃。 +
+