开播:摄像头选择+屏幕共享小窗;观众双路视频;去后台长提示与精简状态

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 11:30:46 +08:00
parent 9329151976
commit 8c9c573a1c
4 changed files with 303 additions and 131 deletions

View File

@@ -1,7 +1,7 @@
/** /**
* 管理后台 WebRTC 开播(需登录 token/api/web/live/ws?role=publish&token= 一致 * 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=
* 画质官网 /live 写入 localStorageyh_live_capture_quality,同浏览器开播时生效;默认原画约束最少。 * 画质官网 /live 写入 localStorageyh_live_capture_quality
* 信令异常断开时自动重连(复用已采集的 MediaStream,不重复弹权限)。 * 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream
*/ */
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '') 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' }] 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) { function humanizeGetUserMediaError(err) {
const name = err && err.name const name = err && err.name
const raw = ((err && err.message) || '').toLowerCase() const raw = ((err && err.message) || '').toLowerCase()
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
return '已拒绝摄像头或麦克风权限:在浏览器地址栏左侧允许摄像头与麦克风,并确认本页为 HTTPS。' return '已拒绝摄像头或麦克风权限。'
} }
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return '未检测到摄像头,请检查是否已接入设备或被系统禁用。' return '未检测到摄像头。'
} }
if ( if (
name === 'NotReadableError' || name === 'NotReadableError' ||
@@ -72,25 +87,34 @@ function humanizeGetUserMediaError(err) {
raw.includes('failed to start video source') || raw.includes('failed to start video source') ||
raw.includes('video source') raw.includes('video source')
) { ) {
return '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。' return '摄像头被占用或不可用,请关闭其它使用相机的程序后重试。'
} }
if (name === 'OverconstrainedError') { if (name === 'OverconstrainedError') {
return '摄像头不满足当前参数约束:请到官网「直播」页换一档画质(或选原画)后再开播。' return '当前摄像头不满足画质约束,请在官网「直播」页改为「原画」或换摄像头。'
} }
if (name === 'AbortError') { if (name === 'AbortError') {
return '打开摄像头被系统中断,请重试。' return '采集被中断,请重试。'
} }
return (err && err.message) || '无法打开摄像头' return (err && err.message) || '无法打开摄像头'
} }
/** /**
* @param {object} opts * @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 {(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 = {}) { export function startPublishing(opts = {}) {
const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts const {
token = '',
captureMode = 'camera',
videoDeviceId = '',
onStatus = () => {},
onLocalStream = () => {}
} = opts
if (!token) { if (!token) {
onStatus('未登录,无法开播') onStatus('未登录,无法开播')
return { stop: () => {} } return { stop: () => {} }
@@ -105,7 +129,6 @@ export function startPublishing(opts = {}) {
let pc = null let pc = null
let reconnectTimer = null let reconnectTimer = null
let reconnectAttempt = 0 let reconnectAttempt = 0
/** 先于关闭旧 WebSocket 递增,避免旧 onclose 误触发重连 */
let wsGen = 0 let wsGen = 0
function clearReconnectTimer() { function clearReconnectTimer() {
@@ -119,8 +142,48 @@ export function startPublishing(opts = {}) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)) 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() { async function ensureStreamAndAttach() {
const cons = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
const needNew = const needNew =
!stream || !stream ||
!stream.getTracks().length || !stream.getTracks().length ||
@@ -131,8 +194,16 @@ export function startPublishing(opts = {}) {
t.stop() t.stop()
} catch (_) {} } catch (_) {}
}) })
stream = await navigator.mediaDevices.getUserMedia(cons) try {
onLocalStream(stream) stream = await acquireMedia()
} catch (e) {
const msg =
e && typeof e.message === 'string' && e.message
? e.message
: humanizeGetUserMediaError(e)
onStatus(msg)
throw e
}
} }
if (pc) { if (pc) {
try { try {
@@ -150,7 +221,7 @@ export function startPublishing(opts = {}) {
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp }) send({ type: 'offer', sdp: offer.sdp })
onStatus('已发起推流协商,等待服务端应答…') onStatus('协商中…')
} }
function onSocketMessage(ev) { function onSocketMessage(ev) {
@@ -165,10 +236,10 @@ export function startPublishing(opts = {}) {
() => { () => {
reconnectAttempt = 0 reconnectAttempt = 0
clearReconnectTimer() clearReconnectTimer()
onStatus('直播中(信令断开会自动重连;结束请点「结束直播」)') onStatus('直播中')
}, },
(e) => { (e) => {
onStatus(e.message || '设置远端描述失败') onStatus(e.message || '协商失败')
} }
) )
} }
@@ -187,7 +258,7 @@ export function startPublishing(opts = {}) {
clearReconnectTimer() clearReconnectTimer()
const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt), 28000) const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt), 28000)
reconnectAttempt += 1 reconnectAttempt += 1
onStatus(`信令断开,${Math.round(delay / 1000)} 秒后自动重连(${reconnectAttempt})…`) onStatus(`信令断开,${Math.round(delay / 1000)} 秒后重连(${reconnectAttempt})…`)
reconnectTimer = window.setTimeout(() => { reconnectTimer = window.setTimeout(() => {
reconnectTimer = null reconnectTimer = null
if (closedByLocal) return if (closedByLocal) return
@@ -214,25 +285,25 @@ export function startPublishing(opts = {}) {
try { try {
ws = new WebSocket(wsUrl) ws = new WebSocket(wsUrl)
} catch (_) { } catch (_) {
onStatus('无法创建信令连接') onStatus('无法连接信令')
scheduleReconnect() scheduleReconnect()
return return
} }
ws.onopen = async () => { ws.onopen = async () => {
if (closedByLocal || myGen !== wsGen) return if (closedByLocal || myGen !== wsGen) return
onStatus('信令已连接,正在采集摄像头…') onStatus('采集中…')
try { try {
await ensureStreamAndAttach() await ensureStreamAndAttach()
} catch (err) { } catch (err) {
onStatus(humanizeGetUserMediaError(err)) if (!closedByLocal) {
onStatus(err?.message || humanizeGetUserMediaError(err))
stop() stop()
} }
} }
}
ws.onmessage = onSocketMessage ws.onmessage = onSocketMessage
ws.onerror = () => { ws.onerror = () => {
if (!closedByLocal) { if (!closedByLocal) onStatus('信令异常')
onStatus('信令 WebSocket 异常(请查 Nginx Upgrade 与网关)')
}
} }
ws.onclose = () => { ws.onclose = () => {
if (myGen !== wsGen) return if (myGen !== wsGen) return
@@ -248,8 +319,6 @@ export function startPublishing(opts = {}) {
} }
} }
openSignalingSocket()
function stop() { function stop() {
closedByLocal = true closedByLocal = true
wsGen += 1 wsGen += 1
@@ -274,5 +343,7 @@ export function startPublishing(opts = {}) {
stream = null stream = null
} }
openSignalingSocket()
return { stop } return { stop }
} }

View File

@@ -4,20 +4,51 @@
<template #header> <template #header>
<span>官网视频直播WebRTC</span> <span>官网视频直播WebRTC</span>
</template> </template>
<p class="tip">
仅后台可开播推流后官网首页左上角以<strong>画中画</strong>自动展示用户也可打开官网直播全屏页观看需站点使用
<strong>HTTPS</strong>公网若观众端<strong>黑屏但信令正常</strong>请在 API 服务环境变量中设置
<code>LIVE_PUBLIC_IP</code>服务器公网 IPv4与域名一致并配置 <code>LIVE_ICE_SERVERS</code> TURN
</p>
<p class="status">{{ status }}</p> <p class="status">{{ status }}</p>
<p class="quality-hint"> <div v-if="!session" class="form-block">
画质请在官网直播页选择写入本机与后台开播使用<strong>同一浏览器</strong>开始直播将按该档位采集 <div class="field-row">
</p> <span class="field-label">画面来源</span>
<el-radio-group v-model="captureMode" :disabled="!token">
<el-radio-button value="camera">仅摄像头</el-radio-button>
<el-radio-button value="screen_pip">屏幕 + 摄像头小窗</el-radio-button>
</el-radio-group>
</div>
<div class="field-row">
<span class="field-label">摄像头</span>
<el-select
v-model="selectedCameraId"
placeholder="默认摄像头"
clearable
filterable
style="width: 100%; max-width: 360px"
:disabled="!token"
>
<el-option label="系统默认" value="" />
<el-option
v-for="d in videoInputs"
:key="d.deviceId"
:label="d.label || `摄像头 ${d.deviceId.slice(0, 8)}…`"
:value="d.deviceId"
/>
</el-select>
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
</div>
</div>
<div class="actions"> <div class="actions">
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button> <el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
<el-button v-else type="danger" @click="stop">结束直播</el-button> <el-button v-else type="danger" @click="stop">结束直播</el-button>
</div> </div>
<video ref="previewRef" class="preview" playsinline muted autoplay></video> <div class="preview-wrap">
<video ref="previewMainRef" class="preview-main" playsinline muted autoplay></video>
<video
v-show="captureMode === 'screen_pip'"
ref="previewPipRef"
class="preview-pip"
playsinline
muted
autoplay
></video>
</div>
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -30,9 +61,31 @@ import { startPublishing } from '../../utils/liveWebRTC'
const authStore = useAuthStore() const authStore = useAuthStore()
const token = computed(() => authStore.getToken() || '') const token = computed(() => authStore.getToken() || '')
const previewRef = ref(null) const previewMainRef = ref(null)
const previewPipRef = ref(null)
const status = ref('就绪') const status = ref('就绪')
const session = ref(null) 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() { function start() {
if (!token.value) { if (!token.value) {
@@ -42,12 +95,12 @@ function start() {
status.value = '正在连接…' status.value = '正在连接…'
const { stop } = startPublishing({ const { stop } = startPublishing({
token: token.value, token: token.value,
captureMode: captureMode.value,
videoDeviceId: selectedCameraId.value || '',
onStatus: (s) => { onStatus: (s) => {
status.value = s status.value = s
}, },
onLocalStream: (stream) => { onLocalStream: applyPreview
if (previewRef.value) previewRef.value.srcObject = stream
}
}) })
session.value = { stop } session.value = { stop }
} }
@@ -55,7 +108,7 @@ function start() {
function stop() { function stop() {
session.value?.stop() session.value?.stop()
session.value = null session.value = null
if (previewRef.value) previewRef.value.srcObject = null clearPreview()
status.value = '已停止' status.value = '已停止'
} }
@@ -66,13 +119,13 @@ function onBeforeUnload() {
onMounted(() => { onMounted(() => {
document.title = '视频直播开播 - 管理后台' document.title = '视频直播开播 - 管理后台'
window.addEventListener('beforeunload', onBeforeUnload) window.addEventListener('beforeunload', onBeforeUnload)
refreshVideoDevices()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload) window.removeEventListener('beforeunload', onBeforeUnload)
}) })
/** 离开本页时再结束推流;勿在 onUnmounted 里 stop避免 Vue 开发严格模式双挂载误关 WebSocket */
onBeforeRouteLeave(() => { onBeforeRouteLeave(() => {
stop() stop()
}) })
@@ -82,39 +135,51 @@ onBeforeRouteLeave(() => {
.live-broadcast { .live-broadcast {
max-width: 720px; 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 { .status {
color: #409eff; color: #409eff;
margin-bottom: 12px; margin-bottom: 14px;
min-height: 1.5em; min-height: 1.5em;
} }
.quality-hint { .form-block {
font-size: 13px; margin-bottom: 16px;
line-height: 1.6; }
color: #909399; .field-row {
margin: 0 0 14px; display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.field-label {
font-size: 14px;
color: #606266;
min-width: 72px;
} }
.actions { .actions {
margin-bottom: 16px; margin-bottom: 16px;
} }
.preview { .preview-wrap {
position: relative;
max-width: 720px;
}
.preview-main {
display: block;
width: 100%; width: 100%;
max-width: 480px; max-height: 70vh;
border-radius: 8px; border-radius: 8px;
background: #000; background: #000;
object-fit: contain;
}
.preview-pip {
position: absolute;
right: 12px;
bottom: 12px;
width: min(28%, 200px);
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
border-radius: 8px;
border: 2px solid #409eff;
background: #000;
object-fit: cover; object-fit: cover;
display: block; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
} }
</style> </style>

View File

@@ -12,12 +12,10 @@ export function liveWsURLView() {
return `${proto}//${window.location.host}${path}` return `${proto}//${window.location.host}${path}`
} }
/** 只读直播元信息GET无请求体 */
export function liveInfoURL() { export function liveInfoURL() {
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info' return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
} }
/** 弹幕 WebSocket发 {"text":"..."},收 {"type":"dm","text","ts"} */
export function liveDanmakuWsURL() { export function liveDanmakuWsURL() {
const path = '/api/web/live/danmaku/ws' const path = '/api/web/live/danmaku/ws'
if (apiBase) { if (apiBase) {
@@ -43,19 +41,15 @@ export async function fetchLiveStatus() {
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }] const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
function newPeer(onTrack) {
const pc = new RTCPeerConnection({ iceServers: defaultIce })
pc.ontrack = onTrack
return pc
}
/** /**
* 观众端:轮询 live 后拉流;结束后自动恢复轮询以便下一场 * 观众端:轮询 live 后拉流;支持双路视频(第一路主画面、第二路摄像头小窗)
* @param {HTMLVideoElement | null} videoEl * @param {HTMLVideoElement | null} videoEl 主画面
* @param {{ onStatus?: (s: string) => void, onLive?: () => void, onEnded?: () => void, pollMs?: number, muted?: boolean }} [opts] * @param {{ pipVideoEl?: HTMLVideoElement | null, onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts]
*/ */
export function startViewing(videoEl, opts = {}) { export function startViewing(videoEl, opts = {}) {
const { const {
pipVideoEl = null,
onPipActive = () => {},
onStatus = () => {}, onStatus = () => {},
onLive = () => {}, onLive = () => {},
onEnded = () => {}, onEnded = () => {},
@@ -63,18 +57,55 @@ export function startViewing(videoEl, opts = {}) {
muted = true muted = true
} = opts } = opts
function attachStreamToVideo(stream) { let mainRecv = new MediaStream()
if (!videoEl || !stream) return let videoIdx = 0
videoEl.srcObject = stream
function syncMain() {
if (!videoEl) return
videoEl.srcObject = mainRecv
videoEl.muted = !!muted videoEl.muted = !!muted
videoEl.play().catch(() => {}) videoEl.play().catch(() => {})
} }
let pc = newPeer((e) => { function clearPip() {
if (videoEl && e.streams[0]) { if (pipVideoEl) {
attachStreamToVideo(e.streams[0]) 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 ws = null
let pollTimer = null let pollTimer = null
let currentPollMs = pollMs let currentPollMs = pollMs
@@ -101,9 +132,7 @@ export function startViewing(videoEl, opts = {}) {
peer.onconnectionstatechange = () => { peer.onconnectionstatechange = () => {
if (stopped) return if (stopped) return
if (peer.connectionState === 'failed') { if (peer.connectionState === 'failed') {
onStatus( onStatus('播放连接失败')
'媒体连接失败ICE服务器若在 Docker/内网,请在环境变量中设置 LIVE_PUBLIC_IP=公网IPv4并在 LIVE_ICE_SERVERS 配置 TURN。'
)
} }
} }
} }
@@ -116,9 +145,7 @@ export function startViewing(videoEl, opts = {}) {
const hasStream = Boolean(videoEl.srcObject) const hasStream = Boolean(videoEl.srcObject)
const noFrame = videoEl.videoWidth === 0 const noFrame = videoEl.videoWidth === 0
if (hasStream && noFrame && peer.connectionState === 'connected') { if (hasStream && noFrame && peer.connectionState === 'connected') {
onStatus( onStatus('暂无画面')
'已连接仍无画面:请确认后台正在推流;公网部署请配置服务端 LIVE_PUBLIC_IP 或 TURNLIVE_ICE_SERVERS。'
)
} }
}, 6000) }, 6000)
} }
@@ -127,24 +154,23 @@ export function startViewing(videoEl, opts = {}) {
try { try {
pc.close() pc.close()
} catch (_) {} } catch (_) {}
pc = newPeer((e) => { mainRecv = new MediaStream()
if (videoEl && e.streams[0]) { videoIdx = 0
attachStreamToVideo(e.streams[0]) clearPip()
} pc = newPeer()
})
} }
function resumePollingAfterDisconnect(tip) { function resumePollingAfterDisconnect(tip) {
if (stopped) return if (stopped) return
clearBlackFrameTimer() clearBlackFrameTimer()
if (videoEl) videoEl.srcObject = null if (videoEl) videoEl.srcObject = null
clearPip()
if (tip) onStatus(tip) if (tip) onStatus(tip)
if (pollTimer) return if (pollTimer) return
rebuildPeer() rebuildPeer()
currentPollMs = pollMs currentPollMs = pollMs
upstreamErrorStreak = 0 upstreamErrorStreak = 0
schedulePollLoop() schedulePollLoop()
// 下一轮再 poll避免从 poll() 内同步调用时 pollInFlight 仍为 true 导致被跳过
setTimeout(() => { setTimeout(() => {
if (!stopped) poll() if (!stopped) poll()
}, 0) }, 0)
@@ -160,6 +186,7 @@ export function startViewing(videoEl, opts = {}) {
else icePending.push(msg) else icePending.push(msg)
} }
pc.addTransceiver('video', { direction: 'recvonly' }) pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' }) pc.addTransceiver('audio', { direction: 'recvonly' })
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
@@ -218,10 +245,8 @@ export function startViewing(videoEl, opts = {}) {
if (upstreamError) { if (upstreamError) {
upstreamErrorStreak += 1 upstreamErrorStreak += 1
const extra = const extra =
upstreamErrorStreak >= 6 upstreamErrorStreak >= 6 ? ' 请稍后再试或刷新页面。' : ''
? ' 若长时间如此,多为服务端未启动或网关异常,请刷新页面或联系管理员。' onStatus(`无法连接服务器,将放慢重试…${extra}`)
: ''
onStatus(`无法连接服务器(网关 502/离线),将放慢重试…${extra}`)
const next = Math.min(Math.round(currentPollMs * 1.5), 30000) const next = Math.min(Math.round(currentPollMs * 1.5), 30000)
if (next !== currentPollMs) { if (next !== currentPollMs) {
currentPollMs = next currentPollMs = next
@@ -240,20 +265,16 @@ export function startViewing(videoEl, opts = {}) {
pollTimer = null pollTimer = null
} }
onLive() onLive()
onStatus('检测到直播,正在连接…') onStatus('连接…')
try { try {
await negotiate() await negotiate()
} catch (err) { } catch (err) {
onStatus( onStatus(err?.message ? `连接失败:${err.message}` : '连接失败,将恢复轮询…')
err?.message
? `拉流连接失败:${err.message}`
: '拉流连接失败,将恢复轮询…'
)
resumePollingAfterDisconnect('') resumePollingAfterDisconnect('')
} }
return return
} }
onStatus('等待主播开播…') onStatus('等待开播…')
} catch { } catch {
onStatus('无法获取直播状态') onStatus('无法获取直播状态')
} finally { } finally {
@@ -276,6 +297,7 @@ export function startViewing(videoEl, opts = {}) {
ws = null ws = null
pc.close() pc.close()
if (videoEl) videoEl.srcObject = null if (videoEl) videoEl.srcObject = null
clearPip()
} }
return { stop } return { stop }

View File

@@ -17,15 +17,23 @@
</option> </option>
</select> </select>
</div> </div>
<p v-if="liveInfoLine" class="live-info-quality">{{ liveInfoLine }}</p>
<p class="live-watch-status">{{ watchStatus }}</p> <p class="live-watch-status">{{ watchStatus }}</p>
<div class="live-video-wrap"> <div class="live-video-wrap">
<video <video
ref="watchVideoRef" ref="watchVideoRef"
class="live-room-video live-room-video--watch" class="live-room-video live-room-video--watch"
:class="{ 'live-room-video--contain': pipVisible }"
playsinline playsinline
autoplay autoplay
></video> ></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 class="live-dm-layer" aria-hidden="true">
<div <div
v-for="d in dmItems" v-for="d in dmItems"
@@ -77,15 +85,16 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { apiBase } from '../config' import { apiBase } from '../config'
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality' import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
import { startViewing, liveDanmakuWsURL, liveInfoURL } from '../utils/liveWebRTC' import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
const watchVideoRef = ref(null) const watchVideoRef = ref(null)
const pipVideoRef = ref(null)
const pipVisible = ref(false)
const rawLiveUrl = ref('') const rawLiveUrl = ref('')
const pageTitle = ref('视频直播') const pageTitle = ref('视频直播')
const watchStatus = ref('正在检测本站直播…') const watchStatus = ref('正在检测本站直播…')
const qualityOptions = LIVE_QUALITY_OPTIONS const qualityOptions = LIVE_QUALITY_OPTIONS
const captureQualityPref = ref('source') const captureQualityPref = ref('source')
const liveInfoLine = ref('')
const dmDraft = ref('') const dmDraft = ref('')
const dmHint = ref('') const dmHint = ref('')
const dmItems = ref([]) const dmItems = ref([])
@@ -101,7 +110,6 @@ const dmSendQueue = []
const enterUrl = computed(() => (rawLiveUrl.value || '').trim()) const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
let viewSession = null let viewSession = null
let liveInfoTimer = null
function loadCaptureQualityPref() { function loadCaptureQualityPref() {
try { try {
@@ -120,17 +128,6 @@ watch(captureQualityPref, (v) => {
} catch (_) {} } catch (_) {}
}) })
async function refreshLiveInfoLine() {
try {
const r = await fetch(liveInfoURL())
if (!r.ok) return
const j = await r.json()
const id = typeof j.current_quality === 'string' && j.current_quality ? j.current_quality : 'source'
const label = qualityOptions.find((o) => o.value === id)?.label || id
liveInfoLine.value = j.live ? `当前在播 · 采集档位:${label}` : ''
} catch (_) {}
}
function normalizeOutboundUrl(u) { function normalizeOutboundUrl(u) {
const s = (u || '').trim() const s = (u || '').trim()
if (!s) return '' if (!s) return ''
@@ -166,6 +163,11 @@ function unmuteAndPlay() {
if (!v) return if (!v) return
v.muted = false v.muted = false
v.play().catch(() => {}) v.play().catch(() => {})
const p = pipVideoRef.value
if (p) {
p.muted = true
p.play().catch(() => {})
}
} }
function toggleVideoFullscreen() { function toggleVideoFullscreen() {
@@ -305,9 +307,11 @@ onMounted(async () => {
loadHomepage() loadHomepage()
await nextTick() await nextTick()
connectDanmaku() connectDanmaku()
refreshLiveInfoLine()
liveInfoTimer = window.setInterval(refreshLiveInfoLine, 8000)
viewSession = startViewing(watchVideoRef.value, { viewSession = startViewing(watchVideoRef.value, {
pipVideoEl: pipVideoRef.value,
onPipActive: (on) => {
pipVisible.value = on
},
muted: false, muted: false,
onStatus: (s) => { onStatus: (s) => {
watchStatus.value = s watchStatus.value = s
@@ -322,11 +326,8 @@ onUnmounted(() => {
clearTimeout(dmReconnectTimer) clearTimeout(dmReconnectTimer)
dmReconnectTimer = null dmReconnectTimer = null
} }
if (liveInfoTimer) {
clearInterval(liveInfoTimer)
liveInfoTimer = null
}
viewSession?.stop() viewSession?.stop()
pipVisible.value = false
try { try {
dmWs?.close() dmWs?.close()
} catch (_) {} } catch (_) {}
@@ -406,11 +407,6 @@ onUnmounted(() => {
color: #fff; color: #fff;
font-size: 14px; font-size: 14px;
} }
.live-info-quality {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
margin: 0 0 8px;
}
.live-watch-status { .live-watch-status {
font-size: 13px; font-size: 13px;
color: #00d4ff; color: #00d4ff;
@@ -562,4 +558,22 @@ onUnmounted(() => {
.live-room-video--watch { .live-room-video--watch {
margin-top: 4px; margin-top: 4px;
} }
.live-room-video--contain {
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> </style>