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

feat: 新增直播监控功能,支持OSS流式上传与摄像头切换

上级 2c4d1c85
......@@ -8,16 +8,27 @@ import AppAside from './Aside.vue'
import AppMain from './Main.vue'
import AppFooter from './Footer.vue'
import { useMediaQuery } from '@vueuse/core'
import { useLiveMonitor } from '@/composables/useLiveMonitor'
import { useAppConfig } from '@/composables/useAppConfig'
import { useUserStore } from '@/stores/user'
const appConfig = useAppConfig()
const userStore = useUserStore()
if (appConfig.liveMonitor && userStore.role?.id === 1) {
useLiveMonitor({ autoStart: true, isOSSUpload: true })
}
const props = withDefaults(defineProps<{ sidebar?: boolean; footer?: boolean; header?: boolean }>(), {
header: true,
sidebar: false,
footer: false
footer: false,
})
const isMobile = useMediaQuery('(max-width: 767px)')
const sidebarVisible = ref(false)
const closeSidebar = () => { sidebarVisible.value = false }
const closeSidebar = () => {
sidebarVisible.value = false
}
const showOverlaySidebar = computed(() => isMobile.value && sidebarVisible.value && props.sidebar)
</script>
......@@ -26,9 +37,7 @@ const showOverlaySidebar = computed(() => isMobile.value && sidebarVisible.value
<AppHeader v-if="header"></AppHeader>
<div class="app-layout-container">
<div v-if="showOverlaySidebar" class="sidebar-overlay" @click="closeSidebar"></div>
<AppAside
v-if="sidebar"
:class="{ 'app-aside--overlay': isMobile, 'app-aside--visible': sidebarVisible }">
<AppAside v-if="sidebar" :class="{ 'app-aside--overlay': isMobile, 'app-aside--visible': sidebarVisible }">
</AppAside>
<AppMain @click="closeSidebar"></AppMain>
</div>
......
......@@ -165,6 +165,7 @@ const appConfigList = [
// loginURL: urls.swsjfxsLogin,
studentMenus: [],
homeUrl: '/student/lab/competition',
liveMonitor: true,
},
]
......
......@@ -20,12 +20,13 @@ export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
interface UseLiveProps {
enabledUserMedia?: boolean
keepChunks?: boolean
onStart?: () => void
onRecord?: (data: Blob) => void
onStop?: (blob: Blob) => void
}
export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }: UseLiveProps) {
export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, onRecord, onStop }: UseLiveProps) {
const startTime = ref(0)
const endTime = ref(0)
const duration = computed(() => (endTime.value - startTime.value) / 1000)
......@@ -36,10 +37,25 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const currentCamera = computed(() => cameras.value[0]?.deviceId)
const currentMicrophone = computed(() => microphones.value[0]?.deviceId)
// 移动端摄像头切换
const facingMode = ref<'user' | 'environment'>('user')
const isFrontCamera = computed(() => facingMode.value === 'user')
// 检测移动端
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || window.innerWidth < 768
// 响应式媒体约束:移动端使用 facingMode,桌面端使用 deviceId
const constraints = computed(() => {
if (isMobile) {
return { video: { facingMode: { ideal: facingMode.value } }, audio: true }
}
return { video: { deviceId: currentCamera.value as any }, audio: { deviceId: currentMicrophone.value as any } }
})
// 创建媒体流
const { stream, restart } = useUserMedia({
enabled: enabledUserMedia,
constraints: { video: { deviceId: currentCamera as any }, audio: { deviceId: currentMicrophone as any } },
constraints: constraints as any,
})
// 录像设置
......@@ -50,7 +66,8 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// 初始化MediaRecorder
const initializeMediaRecorder = () => {
if (!stream.value) return
mediaRecorder = new MediaRecorder(stream.value, { mimeType: 'video/webm' })
const mimeType = MediaRecorder.isTypeSupported('video/mp4') ? 'video/mp4' : 'video/webm'
mediaRecorder = new MediaRecorder(stream.value, { mimeType })
mediaRecorder.ondataavailable = handleDataAvailable
mediaRecorder.onstart = handleStart
mediaRecorder.onstop = handleStop
......@@ -59,7 +76,7 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// 数据可用时处理
const handleDataAvailable = (event: BlobEvent) => {
if (event.data.size > 0) {
recordedChunks.value.push(event.data)
if (keepChunks) recordedChunks.value.push(event.data)
onRecord && onRecord(event.data)
}
}
......@@ -68,7 +85,8 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const handleStart = () => {
startTime.value = Date.now()
onStart && onStart()
// Update currentTime every 100ms while recording
// Update currentTime every 5s while recording
timeUpdateInterval = setInterval(() => {
currentTime.value = Math.floor((Date.now() - startTime.value) / 1000)
}, 1000 * 5)
......@@ -92,7 +110,7 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const start = () => {
if (!mediaRecorder) initializeMediaRecorder()
recordedChunks.value = []
mediaRecorder?.start(100) // 每100ms触发一次dataavailable事件
mediaRecorder?.start(3000)
}
// 停止录制
......@@ -103,13 +121,31 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// 重新开始
const handleRestart = async () => {
// 停止录制
stop()
// 重新获取流
await restart()
// 开始录制
start()
}
return { stream, start, stop, restart: handleRestart, startTime, endTime, duration, currentTime }
// 切换摄像头(移动端前置/后置)
const switchCamera = () => {
if (mediaRecorder?.state === 'recording') return
facingMode.value = facingMode.value === 'user' ? 'environment' : 'user'
// 流自动更新后重新初始化 MediaRecorder
nextTick(() => initializeMediaRecorder())
}
return {
stream,
start,
stop,
restart: handleRestart,
startTime,
endTime,
duration,
currentTime,
switchCamera,
facingMode,
isFrontCamera,
isMobile,
}
}
import { useUserStore } from '@/stores/user'
import { useLive, readBlobAsBase64 } from '@/composables/useLive'
import { useSocket } from '@/composables/useSocket'
import { appendUpload } from '@/utils/oss'
import md5 from 'blueimp-md5'
import { ElMessageBox } from 'element-plus'
import { usePermission, useIntervalFn } from '@vueuse/core'
export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } = {}) {
export function useLiveMonitor({
autoStart = false,
isOSSUpload = true,
}: {
autoStart?: boolean
isOSSUpload?: boolean
} = {}) {
const userStore = useUserStore()
const ssoId = userStore.user?.id
const fileUrl = ref('')
const fileName = computed(() => md5(`${ssoId}${startTime.value}`))
// WebSocket 设置
const { send } = useSocket({
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '')
let ossResult: any = null
let send: (data: string) => void = () => {}
if (!isOSSUpload) {
const socket = useSocket({
onMessage: (ws, event) => {
try {
const data = JSON.parse(event.data)
......@@ -24,17 +36,33 @@ export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } =
}
},
})
send = socket.send
}
const { startTime, start, stop, restart } = useLive({
const { startTime, start, stop, restart, stream } = useLive({
enabledUserMedia: autoStart,
keepChunks: false,
onRecord: async (blob) => {
if (isOSSUpload) {
try {
ossResult = await appendUpload(blob, {
name: `date_${dateStr}/user_${ssoId}/${fileName.value}.mp4`,
position: ossResult?.nextAppendPosition,
})
fileUrl.value = ossResult.url
} catch (error) {
console.error('OSS append upload failed:', error)
}
} else {
const base64Data = await readBlobAsBase64(blob)
const jsonData = JSON.stringify({
send(
JSON.stringify({
type: 'send',
sso_id: ssoId,
data: { type: 'video_rtc', channel: 'rtc', data: { video: base64Data, file_name: fileName.value } },
})
send(jsonData)
}),
)
}
},
})
......@@ -44,13 +72,8 @@ export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } =
const showMessageBox = () => {
if (hasMessageBox.value) return
hasMessageBox.value = true
ElMessageBox.alert('本次考试要求全程开启摄像头,请点击‘确定’允许摄像头访问,以便正常参加考试。', '温馨提示', {
ElMessageBox.alert('本次考试要求全程开启摄像头,请点击"确定"允许摄像头访问,以便正常参加考试。', '温馨提示', {
confirmButtonText: '确定',
// beforeClose: (action, instance, done) => {
// console.log('stream', stream.value)
// console.log('cameraPermission', cameraPermission.value)
// if (stream.value && cameraPermission.value === 'granted') done()
// },
callback: () => {
hasMessageBox.value = false
restart()
......@@ -62,11 +85,36 @@ export function useLiveMonitor({ autoStart = false }: { autoStart?: boolean } =
if (cameraPermission.value === 'denied') showMessageBox()
}, 1000 * 10)
// iframe 直播协作:直播开始时暂停监控释放摄像头,直播结束后恢复
const monitored = ref(false)
const monitorPause = () => {
if (!monitored.value) return
stop()
stream.value?.getTracks().forEach((t) => t.stop())
monitored.value = false
}
const monitorResume = async () => {
if (monitored.value) return
await restart()
start()
monitored.value = true
}
const handleIframeMessage = (event: MessageEvent) => {
if (event.data?.action === 'monitor-pause') monitorPause()
if (event.data?.action === 'monitor-resume') monitorResume()
}
onMounted(() => {
if (autoStart) showMessageBox()
window.addEventListener('message', handleIframeMessage)
if (autoStart) {
monitored.value = true
showMessageBox()
}
})
onUnmounted(() => {
window.removeEventListener('message', handleIframeMessage)
stop()
})
......
......@@ -13,10 +13,8 @@ import html2pdf from 'html2pdf.js'
import { useCookies } from '@vueuse/integrations/useCookies'
import { useAppConfig } from '@/composables/useAppConfig'
import { urls } from '@/utils/env'
// 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'))
......
import OSS from 'ali-oss'
import md5 from 'blueimp-md5'
import { getToken } from '@/api/base'
let store: OSS | null = null
async function getTokenInfo() {
const response: any = await getToken()
return {
accessKeyId: response.Credentials.AccessKeyId,
accessKeySecret: response.Credentials.AccessKeySecret,
stsToken: response.Credentials.SecurityToken,
}
}
async function createStore() {
if (store) return store
const response = await getTokenInfo()
return (store = new OSS({
bucket: 'webapp-pub',
region: 'oss-cn-beijing',
accessKeyId: response.accessKeyId,
accessKeySecret: response.accessKeySecret,
stsToken: response.stsToken,
refreshSTSToken: () => getTokenInfo(),
cname: true,
endpoint: 'https://webapp-pub.ezijing.com',
}))
}
async function parseParams(file: Blob | File, options: any = {}) {
const { name, prefix = 'upload/saas-lab/monitor/', ...rest } = options
let objectName: string
if (name) {
objectName = `${prefix}${name}`
} else {
const ext = file instanceof File ? file.name.split('.').pop() || 'mp4' : 'mp4'
objectName = `${prefix}${md5(new Date().getTime() + Math.random().toString(36).slice(-8))}.${ext}`
}
return { name: objectName, file, options: rest }
}
/** 追加上传 — 录制时流式上传视频 chunk */
export async function appendUpload(file: Blob | File, options?: any) {
const store = await createStore()
const params = await parseParams(file, options)
return store.append(params.name, params.file, params.options)
}
/** 分片上传 — 完整文件一次性上传 */
export async function multipartUpload(file: Blob | File, options?: any) {
const store = await createStore()
const params = await parseParams(file, options)
const res: any = await store.multipartUpload(params.name, params.file, params.options)
return { url: `https://webapp-pub.ezijing.com/${res.name}`, ...res }
}
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论