提交 e6f35ea5 authored 作者: 王鹏飞's avatar 王鹏飞

feat: add live monitor feature to app configuration

上级 229242a6
......@@ -38,6 +38,7 @@ const appConfigList = [
],
},
],
liveMonitor: true,
},
{
system: 'game',
......
import { useDevicesList, useUserMedia } from '@vueuse/core'
export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
try {
const base64Data = (reader.result as string).split(',')[2]
resolve(base64Data)
} catch (error) {
reject(error)
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
reader.readAsDataURL(blob)
})
}
interface UseLiveProps {
enabledUserMedia?: boolean
onStart?: () => void
onRecord?: (data: Blob) => void
onStop?: (blob: Blob) => void
}
export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }: UseLiveProps) {
const startTime = ref(0)
const endTime = ref(0)
const duration = computed(() => (endTime.value - startTime.value) / 1000)
const currentTime = ref(0)
// 获取设备列表并设置默认设备
const { videoInputs: cameras, audioInputs: microphones } = useDevicesList({ requestPermissions: true })
const currentCamera = computed(() => cameras.value[0]?.deviceId)
const currentMicrophone = computed(() => microphones.value[0]?.deviceId)
// 创建媒体流
const { stream } = useUserMedia({
enabled: enabledUserMedia,
constraints: { video: { deviceId: currentCamera as any }, audio: { deviceId: currentMicrophone as any } },
})
// 录像设置
const recordedChunks = ref<Blob[]>([])
let mediaRecorder: MediaRecorder | null = null
let timeUpdateInterval: number | null = null
// 初始化MediaRecorder
const initializeMediaRecorder = () => {
if (!stream.value) return
mediaRecorder = new MediaRecorder(stream.value, { mimeType: 'video/webm' })
mediaRecorder.ondataavailable = handleDataAvailable
mediaRecorder.onstart = handleStart
mediaRecorder.onstop = handleStop
}
// 数据可用时处理
const handleDataAvailable = (event: BlobEvent) => {
if (event.data.size > 0) {
recordedChunks.value.push(event.data)
onRecord && onRecord(event.data)
}
}
// 录像开始时处理
const handleStart = () => {
startTime.value = Date.now()
onStart && onStart()
// Update currentTime every 100ms while recording
timeUpdateInterval = setInterval(() => {
currentTime.value = Math.floor((Date.now() - startTime.value) / 1000)
}, 1000 * 5)
}
// 录像停止时处理
const handleStop = () => {
endTime.value = Date.now()
const blob = new Blob(recordedChunks.value, { type: mediaRecorder?.mimeType })
onStop && onStop(blob)
recordedChunks.value = []
// Clear the interval when recording stops
if (timeUpdateInterval !== null) {
clearInterval(timeUpdateInterval)
timeUpdateInterval = null
}
}
// 开始录制
const start = () => {
if (!mediaRecorder) initializeMediaRecorder()
recordedChunks.value = []
mediaRecorder?.start(100) // 每100ms触发一次dataavailable事件
}
// 停止录制
const stop = () => {
if (mediaRecorder) mediaRecorder.stop()
}
return { stream, start, stop, startTime, endTime, duration, currentTime }
}
import { useUserStore } from '@/stores/user'
import { useLive, readBlobAsBase64 } from '@/composables/useLive'
import { useSocket } from '@/composables/useSocket'
import md5 from 'blueimp-md5'
import { ElMessageBox } from 'element-plus'
export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } = {}) {
const userStore = useUserStore()
const ssoId = userStore.user?.id
const fileUrl = ref('')
const fileName = computed(() => md5(`${ssoId}${startTime.value}`))
// WebSocket 设置
const { send } = useSocket({
onMessage: (ws, event) => {
try {
const data = JSON.parse(event.data)
if (data?.type === 'video_rtc') {
fileUrl.value = data.data.uri
}
} catch (error) {
console.error('Failed to parse message:', error)
}
},
})
const { stream, startTime, start, stop } = useLive({
onRecord: async (blob) => {
const base64Data = await readBlobAsBase64(blob)
const jsonData = JSON.stringify({
type: 'send',
sso_id: ssoId,
data: { type: 'video_rtc', channel: 'rtc', data: { video: base64Data, file_name: fileName.value } },
})
send(jsonData)
},
})
watchEffect(() => {
if (autoStart) {
ElMessageBox.alert('本次考试要求全程开启摄像头,请点击‘确定’允许摄像头访问,以便正常参加考试。', '温馨提示', {
confirmButtonText: '确定',
beforeClose: (action, instance, done) => {
if (stream.value) done()
},
callback: () => {
start()
},
})
}
})
onUnmounted(() => {
stop()
})
return { fileUrl, fileName, start, stop }
}
import { useWebSocket, type UseWebSocketOptions } from '@vueuse/core'
import { useUserStore } from '@/stores/user'
export function useSocket(options: UseWebSocketOptions) {
const userStore = useUserStore()
const ssoId = userStore.user?.id
const defaultOptions = {
autoReconnect: true,
onConnected: () => {
send(JSON.stringify({ type: 'login', sso_id: ssoId }))
},
heartbeat: { message: JSON.stringify({ type: 'health', sso_id: ssoId }), interval: 1000 * 50, pongTimeout: 1000 },
}
const { status, data, send, open, close } = useWebSocket('wss://saas-lab-api.ezijing.com/wss', {
...defaultOptions,
...options,
})
return { status, data, send, open, close }
}
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { useLive, readBlobAsBase64 } from '@/composables/useLive'
import { useSocket } from '@/composables/useSocket'
import md5 from 'blueimp-md5'
const userStore = useUserStore()
const ssoId = userStore.user?.id
const fileUrl = ref('')
const fileName = computed(() => md5(`${ssoId}${startTime.value}`))
// WebSocket 设置
const { send } = useSocket({
onMessage: (ws, event) => {
try {
const data = JSON.parse(event.data)
if (data?.type === 'video_rtc') {
fileUrl.value = data.data.uri
}
} catch (error) {
console.error('Failed to parse message:', error)
}
},
})
const { startTime, start, stop } = useLive({
onRecord: async (blob) => {
console.log('onRecord', blob)
const base64Data = await readBlobAsBase64(blob)
const jsonData = JSON.stringify({
type: 'send',
sso_id: ssoId,
data: { type: 'video_rtc', channel: 'rtc', data: { video: base64Data, file_name: fileName.value } },
})
send(jsonData)
},
})
onMounted(() => {
start()
})
onUnmounted(() => {
stop()
})
</script>
<template>
<div class="live"></div>
</template>
......@@ -11,7 +11,10 @@ import { saveAs } from 'file-saver'
import html2pdf from 'html2pdf.js'
import { useCookies } from '@vueuse/integrations/useCookies'
import { useAppConfig } from '@/composables/useAppConfig'
import { useLiveMonitor } from '@/composables/useLiveMonitor'
const appConfig = useAppConfig()
useLiveMonitor({ autoStart: appConfig.liveMonitor })
const Question = defineAsyncComponent(() => import('../components/Question.vue'))
const Info = defineAsyncComponent(() => import('../components/Info.vue'))
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论