From 8c9c573a1cf683ba94197460167a42460f64fd75 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Thu, 26 Mar 2026 11:30:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E6=92=AD=EF=BC=9A=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E9=80=89=E6=8B=A9+=E5=B1=8F=E5=B9=95=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=B0=8F=E7=AA=97=EF=BC=9B=E8=A7=82=E4=BC=97=E5=8F=8C?= =?UTF-8?q?=E8=B7=AF=E8=A7=86=E9=A2=91=EF=BC=9B=E5=8E=BB=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=95=BF=E6=8F=90=E7=A4=BA=E4=B8=8E=E7=B2=BE=E7=AE=80=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- admin/src/utils/liveWebRTC.js | 127 +++++++++++++++++----- admin/src/views/sites/LiveBroadcast.vue | 137 +++++++++++++++++------- web/src/utils/liveWebRTC.js | 104 +++++++++++------- web/src/views/LiveRoom.vue | 66 +++++++----- 4 files changed, 303 insertions(+), 131 deletions(-) diff --git a/admin/src/utils/liveWebRTC.js b/admin/src/utils/liveWebRTC.js index 96ba5e2..b32deff 100644 --- a/admin/src/utils/liveWebRTC.js +++ b/admin/src/utils/liveWebRTC.js @@ -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 } } diff --git a/admin/src/views/sites/LiveBroadcast.vue b/admin/src/views/sites/LiveBroadcast.vue index 017f933..5387cae 100644 --- a/admin/src/views/sites/LiveBroadcast.vue +++ b/admin/src/views/sites/LiveBroadcast.vue @@ -4,20 +4,51 @@ -

- 仅后台可开播:推流后,官网首页左上角以画中画自动展示,用户也可打开官网「直播」全屏页观看。需站点使用 - HTTPS。公网若观众端黑屏但信令正常,请在 API 服务环境变量中设置 - LIVE_PUBLIC_IP(服务器公网 IPv4,与域名一致),并配置 LIVE_ICE_SERVERS(含 TURN)。 -

{{ status }}

-

- 画质请在官网「直播」页选择(写入本机);与后台开播使用同一浏览器时,开始直播将按该档位采集。 -

+
+
+ 画面来源 + + 仅摄像头 + 屏幕 + 摄像头小窗 + +
+
+ 摄像头 + + + + + 刷新列表 +
+
开始直播 结束直播
- +
+ + +
@@ -30,9 +61,31 @@ import { startPublishing } from '../../utils/liveWebRTC' const authStore = useAuthStore() const token = computed(() => authStore.getToken() || '') -const previewRef = ref(null) +const previewMainRef = ref(null) +const previewPipRef = ref(null) const status = ref('就绪') const session = ref(null) +const captureMode = ref('camera') +const selectedCameraId = ref('') +const videoInputs = ref([]) + +async function refreshVideoDevices() { + try { + if (!navigator.mediaDevices?.enumerateDevices) return + const list = await navigator.mediaDevices.enumerateDevices() + videoInputs.value = list.filter((d) => d.kind === 'videoinput') + } catch (_) {} +} + +function applyPreview({ main, pip }) { + if (previewMainRef.value) previewMainRef.value.srcObject = main || null + if (previewPipRef.value) previewPipRef.value.srcObject = pip || null +} + +function clearPreview() { + if (previewMainRef.value) previewMainRef.value.srcObject = null + if (previewPipRef.value) previewPipRef.value.srcObject = null +} function start() { if (!token.value) { @@ -42,12 +95,12 @@ function start() { status.value = '正在连接…' const { stop } = startPublishing({ token: token.value, + captureMode: captureMode.value, + videoDeviceId: selectedCameraId.value || '', onStatus: (s) => { status.value = s }, - onLocalStream: (stream) => { - if (previewRef.value) previewRef.value.srcObject = stream - } + onLocalStream: applyPreview }) session.value = { stop } } @@ -55,7 +108,7 @@ function start() { function stop() { session.value?.stop() session.value = null - if (previewRef.value) previewRef.value.srcObject = null + clearPreview() status.value = '已停止' } @@ -66,13 +119,13 @@ function onBeforeUnload() { onMounted(() => { document.title = '视频直播开播 - 管理后台' window.addEventListener('beforeunload', onBeforeUnload) + refreshVideoDevices() }) onUnmounted(() => { window.removeEventListener('beforeunload', onBeforeUnload) }) -/** 离开本页时再结束推流;勿在 onUnmounted 里 stop,避免 Vue 开发严格模式双挂载误关 WebSocket */ onBeforeRouteLeave(() => { stop() }) @@ -82,39 +135,51 @@ onBeforeRouteLeave(() => { .live-broadcast { max-width: 720px; } -.tip { - font-size: 13px; - line-height: 1.7; - color: #606266; - margin-bottom: 16px; -} -.tip code { - font-size: 12px; - background: #f4f4f5; - padding: 2px 6px; - border-radius: 4px; -} .status { color: #409eff; - margin-bottom: 12px; + margin-bottom: 14px; min-height: 1.5em; } -.quality-hint { - font-size: 13px; - line-height: 1.6; - color: #909399; - margin: 0 0 14px; +.form-block { + margin-bottom: 16px; +} +.field-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.field-label { + font-size: 14px; + color: #606266; + min-width: 72px; } .actions { margin-bottom: 16px; } -.preview { +.preview-wrap { + position: relative; + max-width: 720px; +} +.preview-main { + display: block; width: 100%; - max-width: 480px; + max-height: 70vh; border-radius: 8px; background: #000; + object-fit: contain; +} +.preview-pip { + position: absolute; + right: 12px; + bottom: 12px; + width: min(28%, 200px); aspect-ratio: 4 / 3; + border-radius: 8px; + border: 2px solid #409eff; + background: #000; object-fit: cover; - display: block; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); } diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js index e285761..52b5969 100644 --- a/web/src/utils/liveWebRTC.js +++ b/web/src/utils/liveWebRTC.js @@ -12,12 +12,10 @@ export function liveWsURLView() { return `${proto}//${window.location.host}${path}` } -/** 只读:直播元信息(GET,无请求体) */ export function liveInfoURL() { return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info' } -/** 弹幕 WebSocket:发 {"text":"..."},收 {"type":"dm","text","ts"} */ export function liveDanmakuWsURL() { const path = '/api/web/live/danmaku/ws' if (apiBase) { @@ -43,19 +41,15 @@ export async function fetchLiveStatus() { const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }] -function newPeer(onTrack) { - const pc = new RTCPeerConnection({ iceServers: defaultIce }) - pc.ontrack = onTrack - return pc -} - /** - * 观众端:轮询 live 后拉流;结束后自动恢复轮询以便下一场 - * @param {HTMLVideoElement | null} videoEl - * @param {{ onStatus?: (s: string) => void, onLive?: () => void, onEnded?: () => void, pollMs?: number, muted?: boolean }} [opts] + * 观众端:轮询 live 后拉流;支持双路视频(第一路主画面、第二路摄像头小窗) + * @param {HTMLVideoElement | null} videoEl 主画面 + * @param {{ pipVideoEl?: HTMLVideoElement | null, onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts] */ export function startViewing(videoEl, opts = {}) { const { + pipVideoEl = null, + onPipActive = () => {}, onStatus = () => {}, onLive = () => {}, onEnded = () => {}, @@ -63,18 +57,55 @@ export function startViewing(videoEl, opts = {}) { muted = true } = opts - function attachStreamToVideo(stream) { - if (!videoEl || !stream) return - videoEl.srcObject = stream + let mainRecv = new MediaStream() + let videoIdx = 0 + + function syncMain() { + if (!videoEl) return + videoEl.srcObject = mainRecv videoEl.muted = !!muted videoEl.play().catch(() => {}) } - let pc = newPeer((e) => { - if (videoEl && e.streams[0]) { - attachStreamToVideo(e.streams[0]) + function clearPip() { + if (pipVideoEl) { + pipVideoEl.srcObject = null } - }) + try { + onPipActive(false) + } catch (_) {} + } + + function handleTrack(e) { + if (e.track.kind === 'audio') { + mainRecv.addTrack(e.track) + syncMain() + return + } + if (e.track.kind === 'video') { + const i = videoIdx++ + if (i === 0) { + mainRecv.addTrack(e.track) + syncMain() + } else if (pipVideoEl) { + const pipMs = new MediaStream([e.track]) + pipVideoEl.srcObject = pipMs + pipVideoEl.muted = true + pipVideoEl.play().catch(() => {}) + try { + onPipActive(true) + } catch (_) {} + } + } + } + + function newPeer() { + const pc = new RTCPeerConnection({ iceServers: defaultIce }) + pc.ontrack = handleTrack + return pc + } + + let pc = newPeer() let ws = null let pollTimer = null let currentPollMs = pollMs @@ -101,9 +132,7 @@ export function startViewing(videoEl, opts = {}) { peer.onconnectionstatechange = () => { if (stopped) return if (peer.connectionState === 'failed') { - onStatus( - '媒体连接失败(ICE):服务器若在 Docker/内网,请在环境变量中设置 LIVE_PUBLIC_IP=公网IPv4,并在 LIVE_ICE_SERVERS 配置 TURN。' - ) + onStatus('播放连接失败') } } } @@ -116,9 +145,7 @@ export function startViewing(videoEl, opts = {}) { const hasStream = Boolean(videoEl.srcObject) const noFrame = videoEl.videoWidth === 0 if (hasStream && noFrame && peer.connectionState === 'connected') { - onStatus( - '已连接仍无画面:请确认后台正在推流;公网部署请配置服务端 LIVE_PUBLIC_IP 或 TURN(LIVE_ICE_SERVERS)。' - ) + onStatus('暂无画面') } }, 6000) } @@ -127,24 +154,23 @@ export function startViewing(videoEl, opts = {}) { try { pc.close() } catch (_) {} - pc = newPeer((e) => { - if (videoEl && e.streams[0]) { - attachStreamToVideo(e.streams[0]) - } - }) + mainRecv = new MediaStream() + videoIdx = 0 + clearPip() + pc = newPeer() } function resumePollingAfterDisconnect(tip) { if (stopped) return clearBlackFrameTimer() if (videoEl) videoEl.srcObject = null + clearPip() if (tip) onStatus(tip) if (pollTimer) return rebuildPeer() currentPollMs = pollMs upstreamErrorStreak = 0 schedulePollLoop() - // 下一轮再 poll,避免从 poll() 内同步调用时 pollInFlight 仍为 true 导致被跳过 setTimeout(() => { if (!stopped) poll() }, 0) @@ -160,6 +186,7 @@ export function startViewing(videoEl, opts = {}) { else icePending.push(msg) } pc.addTransceiver('video', { direction: 'recvonly' }) + pc.addTransceiver('video', { direction: 'recvonly' }) pc.addTransceiver('audio', { direction: 'recvonly' }) const offer = await pc.createOffer() await pc.setLocalDescription(offer) @@ -218,10 +245,8 @@ export function startViewing(videoEl, opts = {}) { if (upstreamError) { upstreamErrorStreak += 1 const extra = - upstreamErrorStreak >= 6 - ? ' 若长时间如此,多为服务端未启动或网关异常,请刷新页面或联系管理员。' - : '' - onStatus(`无法连接服务器(网关 502/离线),将放慢重试…${extra}`) + upstreamErrorStreak >= 6 ? ' 请稍后再试或刷新页面。' : '' + onStatus(`无法连接服务器,将放慢重试…${extra}`) const next = Math.min(Math.round(currentPollMs * 1.5), 30000) if (next !== currentPollMs) { currentPollMs = next @@ -240,20 +265,16 @@ export function startViewing(videoEl, opts = {}) { pollTimer = null } onLive() - onStatus('检测到直播,正在连接…') + onStatus('连接中…') try { await negotiate() } catch (err) { - onStatus( - err?.message - ? `拉流连接失败:${err.message}` - : '拉流连接失败,将恢复轮询…' - ) + onStatus(err?.message ? `连接失败:${err.message}` : '连接失败,将恢复轮询…') resumePollingAfterDisconnect('') } return } - onStatus('等待主播开播…') + onStatus('等待开播…') } catch { onStatus('无法获取直播状态') } finally { @@ -276,6 +297,7 @@ export function startViewing(videoEl, opts = {}) { ws = null pc.close() if (videoEl) videoEl.srcObject = null + clearPip() } return { stop } diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue index 5fee133..20cefb0 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -17,15 +17,23 @@ -

{{ liveInfoLine }}

{{ watchStatus }}

+