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 }}

-
+
画面来源 - + 仅摄像头 - 屏幕 + 摄像头小窗 + 仅共享屏幕 + 共享屏幕 + 摄像头
@@ -21,7 +22,7 @@ clearable filterable style="width: 100%; max-width: 360px" - :disabled="!token" + :disabled="!token || switchingCapture" > 刷新列表
+

+ 直播中可切换三种模式;「屏幕+摄像头」下可拖动小窗,观众画面与预览一致(画布 16:9 + 铺满)。共享整屏时尽量勿把本管理页选进画面,以免套娃。 +

+
+ + 应用切换(不切断直播) + +
开始直播 结束直播
-
- +
+ +
@@ -61,13 +95,80 @@ import { startPublishing } from '../../utils/liveWebRTC' const authStore = useAuthStore() const token = computed(() => authStore.getToken() || '') +const previewWrapRef = ref(null) const previewMainRef = ref(null) -const previewPipRef = ref(null) +const previewScreenRef = ref(null) +const previewCamRef = ref(null) const status = ref('就绪') const session = ref(null) const captureMode = ref('camera') const selectedCameraId = ref('') const videoInputs = ref([]) +const switchingCapture = ref(false) +/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */ +const previewLayout = ref('camera') + +/** 与推流画布 1280×720 一致的归一化小窗矩形(左上 + 宽高,0~1) */ +const pipNorm = ref(defaultPipNorm()) + +function defaultPipNorm() { + const nw = 0.24 + const nh = 0.24 + return { + nx: 1 - nw - 10 / 1280, + ny: 1 - nh - 10 / 720, + nw, + nh + } +} + +const pipStyle = computed(() => ({ + left: `${pipNorm.value.nx * 100}%`, + top: `${pipNorm.value.ny * 100}%`, + width: `${pipNorm.value.nw * 100}%`, + height: `${pipNorm.value.nh * 100}%` +})) + +let pipDragging = false +let pipDragStart = { cx: 0, cy: 0, nx: 0, ny: 0 } + +function clamp01(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)) +} + +function onPipPointerDown(e) { + if (previewLayout.value !== 'screen_pip') return + pipDragging = true + pipDragStart.cx = e.clientX + pipDragStart.cy = e.clientY + pipDragStart.nx = pipNorm.value.nx + pipDragStart.ny = pipNorm.value.ny + try { + e.target.setPointerCapture(e.pointerId) + } catch (_) {} + window.addEventListener('pointermove', onPipPointerMove) + window.addEventListener('pointerup', onPipPointerUp, { once: true }) + window.addEventListener('pointercancel', onPipPointerUp, { once: true }) +} + +function onPipPointerMove(e) { + if (!pipDragging || !previewWrapRef.value) return + const rect = previewWrapRef.value.getBoundingClientRect() + if (rect.width < 1 || rect.height < 1) return + const dx = (e.clientX - pipDragStart.cx) / rect.width + const dy = (e.clientY - pipDragStart.cy) / rect.height + const { nw, nh } = pipNorm.value + pipNorm.value = { + ...pipNorm.value, + nx: clamp01(pipDragStart.nx + dx, 0, 1 - nw), + ny: clamp01(pipDragStart.ny + dy, 0, 1 - nh) + } +} + +function onPipPointerUp() { + pipDragging = false + window.removeEventListener('pointermove', onPipPointerMove) +} async function refreshVideoDevices() { try { @@ -77,14 +178,36 @@ async function refreshVideoDevices() { } catch (_) {} } -function applyPreview({ main, pip }) { - if (previewMainRef.value) previewMainRef.value.srcObject = main || null - if (previewPipRef.value) previewPipRef.value.srcObject = pip || null +function applyPreview(payload) { + const { layout, main, screen, cam } = payload || {} + previewLayout.value = layout || 'camera' + if (previewLayout.value === 'screen_pip') { + if (previewMainRef.value) previewMainRef.value.srcObject = null + if (previewScreenRef.value) previewScreenRef.value.srcObject = screen || null + if (previewCamRef.value) previewCamRef.value.srcObject = cam || null + } else { + if (previewScreenRef.value) previewScreenRef.value.srcObject = null + if (previewCamRef.value) previewCamRef.value.srcObject = null + if (previewMainRef.value) previewMainRef.value.srcObject = main || null + } } function clearPreview() { + previewLayout.value = 'camera' + pipNorm.value = defaultPipNorm() if (previewMainRef.value) previewMainRef.value.srcObject = null - if (previewPipRef.value) previewPipRef.value.srcObject = null + if (previewScreenRef.value) previewScreenRef.value.srcObject = null + if (previewCamRef.value) previewCamRef.value.srcObject = null +} + +async function applyCaptureSwitch() { + if (!session.value?.switchMode) return + switchingCapture.value = true + try { + await session.value.switchMode(captureMode.value, selectedCameraId.value || '') + } finally { + switchingCapture.value = false + } } function start() { @@ -93,16 +216,20 @@ function start() { return } status.value = '正在连接…' - const { stop } = startPublishing({ + const { stop, switchMode } = startPublishing({ token: token.value, captureMode: captureMode.value, videoDeviceId: selectedCameraId.value || '', onStatus: (s) => { status.value = s }, - onLocalStream: applyPreview + onLocalStream: applyPreview, + onActiveModeChange: (m) => { + captureMode.value = m + }, + getPipRect: () => ({ ...pipNorm.value }) }) - session.value = { stop } + session.value = { stop, switchMode } } function stop() { @@ -124,6 +251,7 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener('beforeunload', onBeforeUnload) + window.removeEventListener('pointermove', onPipPointerMove) }) onBeforeRouteLeave(() => { @@ -155,6 +283,13 @@ onBeforeRouteLeave(() => { color: #606266; min-width: 72px; } +.hint-live { + margin: 0 0 10px; + font-size: 13px; + line-height: 1.5; + color: #909399; + max-width: 720px; +} .actions { margin-bottom: 16px; } @@ -171,17 +306,24 @@ onBeforeRouteLeave(() => { border-radius: 8px; background: #000; object-fit: contain; + aspect-ratio: 16 / 9; } -.preview-pip { +.preview-main--fill { + object-fit: fill; +} +.preview-pip-drag { position: absolute; - right: 14px; - bottom: 14px; - width: min(32%, 280px); - aspect-ratio: 4 / 3; + box-sizing: border-box; border-radius: 8px; - border: 2px solid #409eff; + border: 3px solid #409eff; background: #000; object-fit: cover; + cursor: grab; + touch-action: none; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + z-index: 2; +} +.preview-pip-drag:active { + cursor: grabbing; } diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js index 52b5969..bf65924 100644 --- a/web/src/utils/liveWebRTC.js +++ b/web/src/utils/liveWebRTC.js @@ -42,23 +42,15 @@ export async function fetchLiveStatus() { const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }] /** - * 观众端:轮询 live 后拉流;支持双路视频(第一路主画面、第二路摄像头小窗) - * @param {HTMLVideoElement | null} videoEl 主画面 - * @param {{ pipVideoEl?: HTMLVideoElement | null, onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts] + * 观众端:轮询 live 后拉流;单路视频 + 音频(主播端 Canvas 合成) + * @param {HTMLVideoElement | null} videoEl + * @param {{ onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts] */ export function startViewing(videoEl, opts = {}) { - const { - pipVideoEl = null, - onPipActive = () => {}, - onStatus = () => {}, - onLive = () => {}, - onEnded = () => {}, - pollMs = 1200, - muted = true - } = opts + const { onStatus = () => {}, onLive = () => {}, onEnded = () => {}, pollMs = 1200, muted = true } = + opts let mainRecv = new MediaStream() - let videoIdx = 0 function syncMain() { if (!videoEl) return @@ -67,15 +59,6 @@ export function startViewing(videoEl, opts = {}) { videoEl.play().catch(() => {}) } - function clearPip() { - if (pipVideoEl) { - pipVideoEl.srcObject = null - } - try { - onPipActive(false) - } catch (_) {} - } - function handleTrack(e) { if (e.track.kind === 'audio') { mainRecv.addTrack(e.track) @@ -83,19 +66,13 @@ export function startViewing(videoEl, opts = {}) { 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(() => {}) + mainRecv.getVideoTracks().forEach((t) => { try { - onPipActive(true) + mainRecv.removeTrack(t) } catch (_) {} - } + }) + mainRecv.addTrack(e.track) + syncMain() } } @@ -155,8 +132,6 @@ export function startViewing(videoEl, opts = {}) { pc.close() } catch (_) {} mainRecv = new MediaStream() - videoIdx = 0 - clearPip() pc = newPeer() } @@ -164,7 +139,6 @@ export function startViewing(videoEl, opts = {}) { if (stopped) return clearBlackFrameTimer() if (videoEl) videoEl.srcObject = null - clearPip() if (tip) onStatus(tip) if (pollTimer) return rebuildPeer() @@ -186,7 +160,6 @@ 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) @@ -297,7 +270,6 @@ 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 20cefb0..06e5755 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -21,19 +21,10 @@
-