直播:三模式采集、Canvas 单轨与可拖动小窗;观众端单路视频

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 14:27:07 +08:00
parent 26e90c30f9
commit f28b80354f
4 changed files with 417 additions and 146 deletions

View File

@@ -1,7 +1,9 @@
/**
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=
* 画质:官网 /live 写入 localStorageyh_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)
}
}

View File

@@ -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 }) {
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;
}
</style>

View File

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

View File

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