直播:三模式采集、Canvas 单轨与可拖动小窗;观众端单路视频
Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user