|
|
|
|
@ -1,5 +1,8 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="video-container">
|
|
|
|
|
<div class="video-container" ref="containerRef">
|
|
|
|
|
<!-- 视频标题 -->
|
|
|
|
|
<div v-if="title" class="video-title" :class="{ 'title-secondary': title === '侧相机' }">{{ title }}</div>
|
|
|
|
|
|
|
|
|
|
<!-- 实时流模式 -->
|
|
|
|
|
<WebRtcPlayer
|
|
|
|
|
v-if="videoMode === 'live'"
|
|
|
|
|
@ -20,22 +23,171 @@
|
|
|
|
|
<p class="empty-title">暂无视频画面</p>
|
|
|
|
|
<p class="empty-hint">点击上方「切换」加载实时流,或上传离线视频</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 截图/录屏工具栏(仅对第二个视频窗口显示) -->
|
|
|
|
|
<div v-if="enableScreenshot || enableRecording" class="media-toolbar">
|
|
|
|
|
<el-tooltip content="截图" placement="top">
|
|
|
|
|
<el-button size="small" circle @click="takeScreenshot">
|
|
|
|
|
<el-icon><Camera /></el-icon>
|
|
|
|
|
</el-button>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
<el-tooltip :content="recording ? '停止录屏' : '录屏'" placement="top">
|
|
|
|
|
<el-button
|
|
|
|
|
size="small"
|
|
|
|
|
circle
|
|
|
|
|
:type="recording ? 'danger' : 'default'"
|
|
|
|
|
@click="toggleRecording"
|
|
|
|
|
>
|
|
|
|
|
<el-icon><VideoCameraFilled /></el-icon>
|
|
|
|
|
</el-button>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 录屏倒计时/状态提示 -->
|
|
|
|
|
<div v-if="recording" class="recording-indicator">
|
|
|
|
|
<span class="rec-dot"></span>
|
|
|
|
|
<span>REC {{ recordingTime }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, watch, nextTick } from 'vue'
|
|
|
|
|
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { Camera, VideoCameraFilled } from '@element-plus/icons-vue'
|
|
|
|
|
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
videoMode: String,
|
|
|
|
|
webrtcUrl: String,
|
|
|
|
|
offlineVideoUrl: String,
|
|
|
|
|
videoAutoplayTrigger: Number
|
|
|
|
|
videoAutoplayTrigger: Number,
|
|
|
|
|
title: { type: String, default: '' },
|
|
|
|
|
enableScreenshot: { type: Boolean, default: false },
|
|
|
|
|
enableRecording: { type: Boolean, default: false }
|
|
|
|
|
})
|
|
|
|
|
defineEmits(['connectionChange'])
|
|
|
|
|
|
|
|
|
|
const offlineVideoRef = ref(null)
|
|
|
|
|
const containerRef = ref(null)
|
|
|
|
|
|
|
|
|
|
// ============ 截图 ============
|
|
|
|
|
function takeScreenshot() {
|
|
|
|
|
const container = containerRef.value
|
|
|
|
|
if (!container) return
|
|
|
|
|
|
|
|
|
|
// 优先取 WebRTC 的 video 元素
|
|
|
|
|
let videoEl = container.querySelector('video')
|
|
|
|
|
if (!videoEl) return
|
|
|
|
|
|
|
|
|
|
const canvas = document.createElement('canvas')
|
|
|
|
|
canvas.width = videoEl.videoWidth || 640
|
|
|
|
|
canvas.height = videoEl.videoHeight || 480
|
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
|
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height)
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a')
|
|
|
|
|
link.download = `screenshot_${Date.now()}.png`
|
|
|
|
|
link.href = canvas.toDataURL('image/png')
|
|
|
|
|
link.click()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ 录屏 ============
|
|
|
|
|
const recording = ref(false)
|
|
|
|
|
const mediaRecorder = ref(null)
|
|
|
|
|
const recordedChunks = ref([])
|
|
|
|
|
const recordingTime = ref('00:00')
|
|
|
|
|
let recordingTimer = null
|
|
|
|
|
let recordingStartTime = 0
|
|
|
|
|
|
|
|
|
|
function updateRecordingTime() {
|
|
|
|
|
const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000)
|
|
|
|
|
const m = String(Math.floor(elapsed / 60)).padStart(2, '0')
|
|
|
|
|
const s = String(elapsed % 60).padStart(2, '0')
|
|
|
|
|
recordingTime.value = `${m}:${s}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleRecording() {
|
|
|
|
|
if (recording.value) {
|
|
|
|
|
stopRecording()
|
|
|
|
|
} else {
|
|
|
|
|
await startRecording()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startRecording() {
|
|
|
|
|
const container = containerRef.value
|
|
|
|
|
if (!container) return
|
|
|
|
|
|
|
|
|
|
// 获取 video 元素
|
|
|
|
|
const videoEl = container.querySelector('video')
|
|
|
|
|
if (!videoEl) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 从 WebRTC 的 video 元素获取流
|
|
|
|
|
let stream = videoEl.captureStream ? videoEl.captureStream(30) : null
|
|
|
|
|
if (!stream && videoEl.srcObject) {
|
|
|
|
|
stream = videoEl.srcObject
|
|
|
|
|
}
|
|
|
|
|
if (!stream) {
|
|
|
|
|
ElMessage.warning('无法获取视频流进行录制')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 优先选择 MP4 格式,不支持则回退 WebM
|
|
|
|
|
const mimeTypes = [
|
|
|
|
|
'video/mp4;codecs=avc1',
|
|
|
|
|
'video/webm;codecs=vp9',
|
|
|
|
|
'video/webm;codecs=vp8',
|
|
|
|
|
'video/webm'
|
|
|
|
|
]
|
|
|
|
|
let selectedMimeType = ''
|
|
|
|
|
for (const mt of mimeTypes) {
|
|
|
|
|
if (MediaRecorder.isTypeSupported(mt)) {
|
|
|
|
|
selectedMimeType = mt
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!selectedMimeType) {
|
|
|
|
|
ElMessage.warning('当前浏览器不支持录屏')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isMp4 = selectedMimeType.startsWith('video/mp4')
|
|
|
|
|
recordedChunks.value = []
|
|
|
|
|
const recorder = new MediaRecorder(stream, { mimeType: selectedMimeType })
|
|
|
|
|
recorder.ondataavailable = (e) => {
|
|
|
|
|
if (e.data.size > 0) recordedChunks.value.push(e.data)
|
|
|
|
|
}
|
|
|
|
|
recorder.onstop = () => {
|
|
|
|
|
const blob = new Blob(recordedChunks.value, { type: selectedMimeType })
|
|
|
|
|
const link = document.createElement('a')
|
|
|
|
|
link.download = `recording_${Date.now()}.${isMp4 ? 'mp4' : 'webm'}`
|
|
|
|
|
link.href = URL.createObjectURL(blob)
|
|
|
|
|
link.click()
|
|
|
|
|
URL.revokeObjectURL(link.href)
|
|
|
|
|
}
|
|
|
|
|
recorder.start(100)
|
|
|
|
|
mediaRecorder.value = recorder
|
|
|
|
|
recording.value = true
|
|
|
|
|
recordingStartTime = Date.now()
|
|
|
|
|
recordingTimer = setInterval(updateRecordingTime, 1000)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ElMessage.error('录屏启动失败: ' + e.message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopRecording() {
|
|
|
|
|
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
|
|
|
|
|
mediaRecorder.value.stop()
|
|
|
|
|
}
|
|
|
|
|
recording.value = false
|
|
|
|
|
if (recordingTimer) {
|
|
|
|
|
clearInterval(recordingTimer)
|
|
|
|
|
recordingTimer = null
|
|
|
|
|
}
|
|
|
|
|
recordingTime.value = '00:00'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 外部触发自动播放(启动算法时)
|
|
|
|
|
watch(() => props.videoAutoplayTrigger, () => {
|
|
|
|
|
@ -43,6 +195,13 @@ watch(() => props.videoAutoplayTrigger, () => {
|
|
|
|
|
nextTick(() => offlineVideoRef.value?.play())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (recordingTimer) clearInterval(recordingTimer)
|
|
|
|
|
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
|
|
|
|
|
mediaRecorder.value.stop()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
@ -54,6 +213,27 @@ watch(() => props.videoAutoplayTrigger, () => {
|
|
|
|
|
position: relative;
|
|
|
|
|
min-height: 300px;
|
|
|
|
|
|
|
|
|
|
.video-title {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 8px;
|
|
|
|
|
left: 8px;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
background: linear-gradient(135deg, rgba(64, 158, 255, 0.9), rgba(64, 158, 255, 0.6));
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
padding: 3px 12px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
backdrop-filter: blur(4px);
|
|
|
|
|
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
|
|
|
|
|
|
|
|
|
|
&.title-secondary {
|
|
|
|
|
background: linear-gradient(135deg, rgba(103, 194, 58, 0.9), rgba(103, 194, 58, 0.6));
|
|
|
|
|
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.webrtc-wrapper) {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
@ -99,4 +279,55 @@ watch(() => props.videoAutoplayTrigger, () => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.media-toolbar {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 8px;
|
|
|
|
|
right: 8px;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
|
|
|
|
.el-button {
|
|
|
|
|
background: rgba(0, 0, 0, 0.6);
|
|
|
|
|
border: none;
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.el-button--danger {
|
|
|
|
|
background: rgba(245, 108, 108, 0.8);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.recording-indicator {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 8px;
|
|
|
|
|
left: 8px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
background: rgba(0, 0, 0, 0.6);
|
|
|
|
|
color: #f56c6c;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
|
|
|
|
.rec-dot {
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: #f56c6c;
|
|
|
|
|
animation: blink 1s step-end infinite;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes blink {
|
|
|
|
|
50% { opacity: 0; }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|