开播:摄像头选择+屏幕共享小窗;观众双路视频;去后台长提示与精简状态
Made-with: Cursor
This commit is contained in:
@@ -4,20 +4,51 @@
|
||||
<template #header>
|
||||
<span>官网视频直播(WebRTC)</span>
|
||||
</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="quality-hint">
|
||||
画质请在官网「直播」页选择(写入本机);与后台开播使用<strong>同一浏览器</strong>时,开始直播将按该档位采集。
|
||||
</p>
|
||||
<div v-if="!session" class="form-block">
|
||||
<div class="field-row">
|
||||
<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">
|
||||
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
||||
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,9 +61,31 @@ import { startPublishing } from '../../utils/liveWebRTC'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const token = computed(() => authStore.getToken() || '')
|
||||
const previewRef = ref(null)
|
||||
const previewMainRef = ref(null)
|
||||
const previewPipRef = ref(null)
|
||||
const status = ref('就绪')
|
||||
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() {
|
||||
if (!token.value) {
|
||||
@@ -42,12 +95,12 @@ function start() {
|
||||
status.value = '正在连接…'
|
||||
const { stop } = startPublishing({
|
||||
token: token.value,
|
||||
captureMode: captureMode.value,
|
||||
videoDeviceId: selectedCameraId.value || '',
|
||||
onStatus: (s) => {
|
||||
status.value = s
|
||||
},
|
||||
onLocalStream: (stream) => {
|
||||
if (previewRef.value) previewRef.value.srcObject = stream
|
||||
}
|
||||
onLocalStream: applyPreview
|
||||
})
|
||||
session.value = { stop }
|
||||
}
|
||||
@@ -55,7 +108,7 @@ function start() {
|
||||
function stop() {
|
||||
session.value?.stop()
|
||||
session.value = null
|
||||
if (previewRef.value) previewRef.value.srcObject = null
|
||||
clearPreview()
|
||||
status.value = '已停止'
|
||||
}
|
||||
|
||||
@@ -66,13 +119,13 @@ function onBeforeUnload() {
|
||||
onMounted(() => {
|
||||
document.title = '视频直播开播 - 管理后台'
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
refreshVideoDevices()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
|
||||
/** 离开本页时再结束推流;勿在 onUnmounted 里 stop,避免 Vue 开发严格模式双挂载误关 WebSocket */
|
||||
onBeforeRouteLeave(() => {
|
||||
stop()
|
||||
})
|
||||
@@ -82,39 +135,51 @@ onBeforeRouteLeave(() => {
|
||||
.live-broadcast {
|
||||
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 {
|
||||
color: #409eff;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
.quality-hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
margin: 0 0 14px;
|
||||
.form-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
min-width: 72px;
|
||||
}
|
||||
.actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.preview {
|
||||
.preview-wrap {
|
||||
position: relative;
|
||||
max-width: 720px;
|
||||
}
|
||||
.preview-main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 70vh;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
object-fit: contain;
|
||||
}
|
||||
.preview-pip {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: min(28%, 200px);
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #409eff;
|
||||
background: #000;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user