直播:三模式采集、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

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