直播:三模式采集、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') {
|
||||
async function acquireDisplayStream() {
|
||||
let display
|
||||
try {
|
||||
display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||
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) {
|
||||
const sTr = display.getVideoTracks()[0]
|
||||
if (!sTr) {
|
||||
display.getTracks().forEach((t) => t.stop())
|
||||
throw new Error('未获得屏幕画面')
|
||||
}
|
||||
screenVideo.addEventListener('ended', () => {
|
||||
screenShareTrack = sTr
|
||||
sTr.addEventListener('ended', () => {
|
||||
if (!closedByLocal) {
|
||||
onStatus('屏幕共享已结束')
|
||||
stop()
|
||||
}
|
||||
})
|
||||
let cam
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
<span>官网视频直播(WebRTC)</span>
|
||||
</template>
|
||||
<p class="status">{{ status }}</p>
|
||||
<div v-if="!session" class="form-block">
|
||||
<div class="form-block">
|
||||
<div class="field-row">
|
||||
<span class="field-label">画面来源</span>
|
||||
<el-radio-group v-model="captureMode" :disabled="!token">
|
||||
<el-radio-group v-model="captureMode" :disabled="!token || switchingCapture">
|
||||
<el-radio-button value="camera">仅摄像头</el-radio-button>
|
||||
<el-radio-button value="screen_pip">屏幕 + 摄像头小窗</el-radio-button>
|
||||
<el-radio-button value="screen_only">仅共享屏幕</el-radio-button>
|
||||
<el-radio-button value="screen_pip">共享屏幕 + 摄像头</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
@@ -21,7 +22,7 @@
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%; max-width: 360px"
|
||||
:disabled="!token"
|
||||
:disabled="!token || switchingCapture"
|
||||
>
|
||||
<el-option label="系统默认" value="" />
|
||||
<el-option
|
||||
@@ -33,21 +34,54 @@
|
||||
</el-select>
|
||||
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
|
||||
</div>
|
||||
<p v-if="session" class="hint-live">
|
||||
直播中可切换三种模式;「屏幕+摄像头」下可拖动小窗,观众画面与预览一致(画布 16:9
|
||||
铺满)。共享整屏时尽量勿把本管理页选进画面,以免套娃。
|
||||
</p>
|
||||
<div v-if="session" class="field-row">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!token || switchingCapture"
|
||||
:loading="switchingCapture"
|
||||
@click="applyCaptureSwitch"
|
||||
>
|
||||
应用切换(不切断直播)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
||||
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
||||
</div>
|
||||
<div class="preview-wrap">
|
||||
<video ref="previewMainRef" class="preview-main" playsinline muted autoplay></video>
|
||||
<div ref="previewWrapRef" class="preview-wrap">
|
||||
<video
|
||||
v-show="captureMode === 'screen_pip'"
|
||||
ref="previewPipRef"
|
||||
class="preview-pip"
|
||||
v-show="previewLayout !== 'screen_pip'"
|
||||
ref="previewMainRef"
|
||||
class="preview-main"
|
||||
playsinline
|
||||
muted
|
||||
autoplay
|
||||
></video>
|
||||
<video
|
||||
v-show="previewLayout === 'screen_pip'"
|
||||
ref="previewScreenRef"
|
||||
class="preview-main preview-main--fill"
|
||||
playsinline
|
||||
muted
|
||||
autoplay
|
||||
></video>
|
||||
<video
|
||||
v-show="previewLayout === 'screen_pip'"
|
||||
ref="previewCamRef"
|
||||
class="preview-pip-drag"
|
||||
:style="pipStyle"
|
||||
playsinline
|
||||
muted
|
||||
autoplay
|
||||
title="拖动调整小窗位置(观众端同步)"
|
||||
@pointerdown.prevent="onPipPointerDown"
|
||||
></video>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -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 }) {
|
||||
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
|
||||
if (previewPipRef.value) previewPipRef.value.srcObject = pip || 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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.getVideoTracks().forEach((t) => {
|
||||
try {
|
||||
mainRecv.removeTrack(t)
|
||||
} catch (_) {}
|
||||
})
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -21,19 +21,10 @@
|
||||
<div class="live-video-wrap">
|
||||
<video
|
||||
ref="watchVideoRef"
|
||||
class="live-room-video live-room-video--watch"
|
||||
:class="{ 'live-room-video--contain': pipVisible }"
|
||||
class="live-room-video live-room-video--watch live-room-video--contain"
|
||||
playsinline
|
||||
autoplay
|
||||
></video>
|
||||
<video
|
||||
v-show="pipVisible"
|
||||
ref="pipVideoRef"
|
||||
class="live-room-pip-video"
|
||||
playsinline
|
||||
autoplay
|
||||
muted
|
||||
></video>
|
||||
<div class="live-dm-layer" aria-hidden="true">
|
||||
<div
|
||||
v-for="d in dmItems"
|
||||
@@ -88,8 +79,6 @@ import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils
|
||||
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
||||
|
||||
const watchVideoRef = ref(null)
|
||||
const pipVideoRef = ref(null)
|
||||
const pipVisible = ref(false)
|
||||
const rawLiveUrl = ref('')
|
||||
const pageTitle = ref('视频直播')
|
||||
const watchStatus = ref('正在检测本站直播…')
|
||||
@@ -163,11 +152,6 @@ function unmuteAndPlay() {
|
||||
if (!v) return
|
||||
v.muted = false
|
||||
v.play().catch(() => {})
|
||||
const p = pipVideoRef.value
|
||||
if (p) {
|
||||
p.muted = true
|
||||
p.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVideoFullscreen() {
|
||||
@@ -308,10 +292,6 @@ onMounted(async () => {
|
||||
await nextTick()
|
||||
connectDanmaku()
|
||||
viewSession = startViewing(watchVideoRef.value, {
|
||||
pipVideoEl: pipVideoRef.value,
|
||||
onPipActive: (on) => {
|
||||
pipVisible.value = on
|
||||
},
|
||||
muted: false,
|
||||
onStatus: (s) => {
|
||||
watchStatus.value = s
|
||||
@@ -327,7 +307,6 @@ onUnmounted(() => {
|
||||
dmReconnectTimer = null
|
||||
}
|
||||
viewSession?.stop()
|
||||
pipVisible.value = false
|
||||
try {
|
||||
dmWs?.close()
|
||||
} catch (_) {}
|
||||
@@ -562,18 +541,4 @@ onUnmounted(() => {
|
||||
object-fit: contain;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.live-room-pip-video {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 30%;
|
||||
max-width: 200px;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(0, 212, 255, 0.65);
|
||||
background: #000;
|
||||
object-fit: cover;
|
||||
z-index: 4;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user