开播:摄像头选择+屏幕共享小窗;观众双路视频;去后台长提示与精简状态
Made-with: Cursor
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
|
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=)
|
||||||
* 画质由官网 /live 写入 localStorage(yh_live_capture_quality),同浏览器开播时生效;默认原画约束最少。
|
* 画质:官网 /live 写入 localStorage(yh_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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 或 TURN(LIVE_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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user