708 lines
24 KiB
Vue
708 lines
24 KiB
Vue
<!--
|
||
设备预览页(Task 10)
|
||
|
||
来自:
|
||
- /pages/meeting/create(mode=create)→ 读 meetingStore.draftCreatePayload 调 createAndEnter
|
||
- /pages/meeting/join(mode=join&code=...&password=...)→ 调 joinAndEnter
|
||
|
||
核心能力:
|
||
- navigator.mediaDevices.enumerateDevices() 列出摄像头/麦克风/扬声器
|
||
- getUserMedia({ audio, video }) 本地画面 + 音频轨预览
|
||
- AudioContext + AnalyserNode 实时音量条(20Hz 采样)
|
||
- 「入会默认开麦/摄像头」两个开关写入 mediaPrefs
|
||
- 兜底:权限被拒 / 浏览器不支持 WebRTC 时给明确错误提示
|
||
|
||
入会成功后:uni.redirectTo('/pages/meeting/room?code=xxx')
|
||
-->
|
||
<template>
|
||
<view v-if="initializing || autoJoin" class="auto-join-page">
|
||
<template v-if="autoJoinError">
|
||
<text class="auto-join-error-title">进入会议失败</text>
|
||
<text class="auto-join-error-msg">{{ autoJoinError }}</text>
|
||
<view class="auto-join-actions">
|
||
<button class="aj-btn aj-btn-primary" @click="retryAutoJoin">重试</button>
|
||
<button class="aj-btn aj-btn-secondary" @click="exitAutoJoin">返回</button>
|
||
</view>
|
||
</template>
|
||
<text v-else class="auto-join-title">正在进入会议…</text>
|
||
</view>
|
||
<view v-else class="page">
|
||
<view class="container">
|
||
<text class="title">设备预览</text>
|
||
<text class="subtitle">{{ modeLabel }},确认设备和显示名称后加入会议</text>
|
||
|
||
<view class="content">
|
||
<view class="preview-box">
|
||
<view ref="videoBox" class="video-slot"></view>
|
||
<view class="preview-footer">
|
||
<view class="volume-wrap">
|
||
<text class="volume-label">麦克风音量</text>
|
||
<view class="volume-bar"><view class="volume-fill" :style="{ width: volumePercent + '%' }"></view></view>
|
||
</view>
|
||
<text v-if="permissionError" class="error">{{ permissionError }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="controls card">
|
||
<view class="field">
|
||
<text class="label">摄像头</text>
|
||
<picker
|
||
:value="videoPickerIndex"
|
||
:range="videoDeviceLabels"
|
||
:disabled="!hasPermission || videoDevices.length === 0"
|
||
@change="onVideoChange"
|
||
>
|
||
<view class="picker">{{ videoDeviceLabels[videoPickerIndex] || '未检测到摄像头' }}</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="label">麦克风</text>
|
||
<picker
|
||
:value="audioPickerIndex"
|
||
:range="audioDeviceLabels"
|
||
:disabled="!hasPermission || audioDevices.length === 0"
|
||
@change="onAudioChange"
|
||
>
|
||
<view class="picker">{{ audioDeviceLabels[audioPickerIndex] || '未检测到麦克风' }}</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="field" v-if="speakerDevices.length > 0">
|
||
<text class="label">扬声器</text>
|
||
<picker
|
||
:value="speakerPickerIndex"
|
||
:range="speakerDeviceLabels"
|
||
@change="onSpeakerChange"
|
||
>
|
||
<view class="picker">{{ speakerDeviceLabels[speakerPickerIndex] || '系统默认' }}</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="label">显示名称</text>
|
||
<input v-model="displayName" class="input" placeholder="会议中显示的名字" maxlength="20" />
|
||
</view>
|
||
|
||
<view class="field switch-row">
|
||
<view class="switch-label">
|
||
<text class="label">入会默认开启摄像头</text>
|
||
</view>
|
||
<switch :checked="mediaPrefs.startVideo" @change="onToggleVideoDefault" color="#3B82F6" />
|
||
</view>
|
||
|
||
<view class="field switch-row">
|
||
<view class="switch-label">
|
||
<text class="label">入会默认开启麦克风</text>
|
||
</view>
|
||
<switch :checked="mediaPrefs.startAudio" @change="onToggleAudioDefault" color="#3B82F6" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="actions">
|
||
<button class="btn btn-secondary" :disabled="joining" @click="onCancel">上一步</button>
|
||
<button class="btn btn-primary" :disabled="joining || !canJoin" @click="onJoin">
|
||
{{ joining ? '加入中…' : '加入会议' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onBeforeUnmount, nextTick } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { useMeetingStore } from '@/store/meeting'
|
||
import { useUserStore } from '@/store/user'
|
||
import { isCloudLawEmbed, isCloudLawLawRole, minimizeCloudLawMeeting } from '@/utils/cloudLawMiniProgram'
|
||
|
||
const meetingStore = useMeetingStore()
|
||
const userStore = useUserStore()
|
||
|
||
const mode = ref('create')
|
||
const joinCode = ref('')
|
||
const joinPassword = ref('')
|
||
const autoJoin = ref(false)
|
||
const cloudLawRole = ref('')
|
||
const cloudLawCallId = ref('')
|
||
const initializing = ref(true)
|
||
// autoJoin 失败/超时的可见错误(替代无限"正在进入会议…"转圈)
|
||
const autoJoinError = ref('')
|
||
let autoJoinWatchdog = null
|
||
|
||
const modeLabel = computed(() => mode.value === 'create' ? '即将创建新会议' : `即将加入会议 ${joinCode.value}`)
|
||
|
||
const videoBox = ref(null)
|
||
let previewStream = null
|
||
let audioContext = null
|
||
let analyser = null
|
||
let volumeRafId = null
|
||
|
||
// P2-2 修复:快速切换摄像头/麦克风时 getUserMedia 并发竞态保护
|
||
// - previewSeq 每调用一次 startPreview 递增,保证只有"最后一次"请求结果被挂载
|
||
// - changeDebounceTimer 合并 200ms 内的连续切换(防止 picker 快速滚动触发多次拉流)
|
||
let previewSeq = 0
|
||
let changeDebounceTimer = null
|
||
|
||
const hasPermission = ref(false)
|
||
const permissionError = ref('')
|
||
const joining = ref(false)
|
||
|
||
const videoDevices = ref([])
|
||
const audioDevices = ref([])
|
||
const speakerDevices = ref([])
|
||
|
||
const selectedVideoId = ref('')
|
||
const selectedAudioId = ref('')
|
||
const selectedSpeakerId = ref('')
|
||
|
||
const videoDeviceLabels = computed(() => videoDevices.value.map((d, i) => d.label || `摄像头 ${i + 1}`))
|
||
const audioDeviceLabels = computed(() => audioDevices.value.map((d, i) => d.label || `麦克风 ${i + 1}`))
|
||
const speakerDeviceLabels = computed(() => speakerDevices.value.map((d, i) => d.label || `扬声器 ${i + 1}`))
|
||
|
||
const videoPickerIndex = computed(() => {
|
||
const i = videoDevices.value.findIndex(d => d.deviceId === selectedVideoId.value)
|
||
return i < 0 ? 0 : i
|
||
})
|
||
const audioPickerIndex = computed(() => {
|
||
const i = audioDevices.value.findIndex(d => d.deviceId === selectedAudioId.value)
|
||
return i < 0 ? 0 : i
|
||
})
|
||
const speakerPickerIndex = computed(() => {
|
||
const i = speakerDevices.value.findIndex(d => d.deviceId === selectedSpeakerId.value)
|
||
return i < 0 ? 0 : i
|
||
})
|
||
|
||
const displayName = ref(userStore.userInfo?.nickname || userStore.userInfo?.username || '')
|
||
const mediaPrefs = reactive({
|
||
startAudio: true,
|
||
startVideo: true
|
||
})
|
||
|
||
const volumePercent = ref(0)
|
||
|
||
const canJoin = computed(() => {
|
||
if (mode.value === 'join' && !joinCode.value) return false
|
||
return true
|
||
})
|
||
|
||
// ==================== 设备 / 预览流 ====================
|
||
|
||
const stopPreviewStream = () => {
|
||
if (previewStream) {
|
||
previewStream.getTracks().forEach(t => { try { t.stop() } catch {} })
|
||
previewStream = null
|
||
}
|
||
}
|
||
|
||
const stopAudioMeter = () => {
|
||
if (volumeRafId) {
|
||
cancelAnimationFrame(volumeRafId)
|
||
volumeRafId = null
|
||
}
|
||
if (audioContext) {
|
||
try { audioContext.close() } catch {}
|
||
audioContext = null
|
||
analyser = null
|
||
}
|
||
volumePercent.value = 0
|
||
}
|
||
|
||
/** 基于当前已选 deviceId 启动预览(无 ID 则首次调用由浏览器分配默认设备) */
|
||
const startPreview = async () => {
|
||
// #ifdef H5
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
// secure context 检查:大多数浏览器只在 HTTPS / localhost 下暴露 mediaDevices
|
||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
||
const host = window.location?.host || ''
|
||
permissionError.value = `当前为不安全连接,浏览器禁止访问摄像头/麦克风。请改用 HTTPS 访问:https://${host}(自签证书首次访问选择"仍然继续"即可)`
|
||
} else {
|
||
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||
}
|
||
return
|
||
}
|
||
const mySeq = ++previewSeq
|
||
stopPreviewStream()
|
||
stopAudioMeter()
|
||
try {
|
||
const constraints = {
|
||
audio: selectedAudioId.value ? { deviceId: { exact: selectedAudioId.value } } : true,
|
||
video: selectedVideoId.value
|
||
? { deviceId: { exact: selectedVideoId.value }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 24, max: 30 } }
|
||
: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 24, max: 30 } }
|
||
}
|
||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||
if (mySeq !== previewSeq) {
|
||
// 期间又触发了新的 startPreview,本次结果已过期,立即关闭 track 丢弃
|
||
stream.getTracks().forEach(t => { try { t.stop() } catch {} })
|
||
return
|
||
}
|
||
previewStream = stream
|
||
hasPermission.value = true
|
||
permissionError.value = ''
|
||
await nextTick()
|
||
if (mySeq !== previewSeq) return
|
||
mountPreviewVideo()
|
||
startAudioMeter(previewStream)
|
||
} catch (err) {
|
||
if (mySeq !== previewSeq) return
|
||
hasPermission.value = false
|
||
permissionError.value = err?.name === 'NotAllowedError'
|
||
? '已拒绝摄像头/麦克风权限,请在浏览器地址栏左侧恢复权限后重试'
|
||
: `无法访问音视频设备:${err?.message || err}`
|
||
console.warn('[Preview] getUserMedia 失败', err)
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
// P2-2 修复:设备切换防抖,避免 picker 快速滚动触发多次 getUserMedia 并发
|
||
const scheduleRestartPreview = () => {
|
||
if (changeDebounceTimer) {
|
||
clearTimeout(changeDebounceTimer)
|
||
}
|
||
changeDebounceTimer = setTimeout(() => {
|
||
changeDebounceTimer = null
|
||
startPreview()
|
||
}, 200)
|
||
}
|
||
|
||
const mountPreviewVideo = () => {
|
||
// #ifdef H5
|
||
const parent = resolveDom(videoBox.value)
|
||
if (!parent || !previewStream) return
|
||
let videoEl = parent.querySelector(':scope > video')
|
||
if (!videoEl) {
|
||
videoEl = document.createElement('video')
|
||
videoEl.autoplay = true
|
||
videoEl.playsInline = true
|
||
videoEl.muted = true // 预览时本地回声静音
|
||
videoEl.style.width = '100%'
|
||
videoEl.style.height = '100%'
|
||
videoEl.style.objectFit = 'cover'
|
||
videoEl.style.background = '#000'
|
||
videoEl.style.borderRadius = '12rpx'
|
||
parent.appendChild(videoEl)
|
||
}
|
||
videoEl.srcObject = previewStream
|
||
// #endif
|
||
}
|
||
|
||
const resolveDom = (refValue) => {
|
||
if (!refValue) return null
|
||
// #ifdef H5
|
||
if (refValue instanceof HTMLElement) return refValue
|
||
if (refValue.$el) return refValue.$el
|
||
if (refValue.exposed && refValue.exposed.$el) return refValue.exposed.$el
|
||
// #endif
|
||
return null
|
||
}
|
||
|
||
/** 用 AudioContext.AnalyserNode 采样 RMS,换算成 0-100 的音量条 */
|
||
const startAudioMeter = (stream) => {
|
||
// #ifdef H5
|
||
const audioTrack = stream.getAudioTracks()[0]
|
||
if (!audioTrack) return
|
||
try {
|
||
const AC = window.AudioContext || window.webkitAudioContext
|
||
if (!AC) return
|
||
audioContext = new AC()
|
||
const source = audioContext.createMediaStreamSource(new MediaStream([audioTrack]))
|
||
analyser = audioContext.createAnalyser()
|
||
analyser.fftSize = 1024
|
||
source.connect(analyser)
|
||
const data = new Uint8Array(analyser.fftSize)
|
||
const loop = () => {
|
||
if (!analyser) return
|
||
analyser.getByteTimeDomainData(data)
|
||
// RMS:((sample-128)/128) 的平方平均
|
||
let sum = 0
|
||
for (let i = 0; i < data.length; i++) {
|
||
const v = (data[i] - 128) / 128
|
||
sum += v * v
|
||
}
|
||
const rms = Math.sqrt(sum / data.length)
|
||
// 经验映射:放大 1.6 倍,截顶 100
|
||
volumePercent.value = Math.min(100, Math.round(rms * 160))
|
||
volumeRafId = requestAnimationFrame(loop)
|
||
}
|
||
loop()
|
||
} catch (err) {
|
||
console.warn('[Preview] AudioMeter 失败', err)
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
const loadDevices = async () => {
|
||
// #ifdef H5
|
||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||
const list = await navigator.mediaDevices.enumerateDevices()
|
||
videoDevices.value = list.filter(d => d.kind === 'videoinput')
|
||
audioDevices.value = list.filter(d => d.kind === 'audioinput')
|
||
speakerDevices.value = list.filter(d => d.kind === 'audiooutput')
|
||
// 有权限后 label 才会返回,没默认选中时自动选第一个
|
||
if (!selectedVideoId.value && videoDevices.value[0]) selectedVideoId.value = videoDevices.value[0].deviceId
|
||
if (!selectedAudioId.value && audioDevices.value[0]) selectedAudioId.value = audioDevices.value[0].deviceId
|
||
if (!selectedSpeakerId.value && speakerDevices.value[0]) selectedSpeakerId.value = speakerDevices.value[0].deviceId
|
||
// #endif
|
||
}
|
||
|
||
// ==================== 事件 ====================
|
||
|
||
const onVideoChange = (e) => {
|
||
const idx = Number(e.detail.value)
|
||
const d = videoDevices.value[idx]
|
||
if (!d) return
|
||
selectedVideoId.value = d.deviceId
|
||
scheduleRestartPreview()
|
||
}
|
||
const onAudioChange = (e) => {
|
||
const idx = Number(e.detail.value)
|
||
const d = audioDevices.value[idx]
|
||
if (!d) return
|
||
selectedAudioId.value = d.deviceId
|
||
scheduleRestartPreview()
|
||
}
|
||
const onSpeakerChange = (e) => {
|
||
const idx = Number(e.detail.value)
|
||
const d = speakerDevices.value[idx]
|
||
if (!d) return
|
||
selectedSpeakerId.value = d.deviceId
|
||
}
|
||
|
||
const onToggleVideoDefault = (e) => { mediaPrefs.startVideo = e.detail.value }
|
||
const onToggleAudioDefault = (e) => { mediaPrefs.startAudio = e.detail.value }
|
||
|
||
const onCancel = () => {
|
||
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
|
||
minimizeCloudLawMeeting(cloudLawCallId.value)
|
||
return
|
||
}
|
||
uni.navigateBack()
|
||
}
|
||
|
||
// autoJoin 看门狗:若 15s 内既没进房也没报错(多见于 WS 连不上 / mediasoup 传输 ICE 不通而无限等待),
|
||
// 主动把无限转圈切换成可见的超时错误,便于真机定位与重试。
|
||
const startAutoJoinWatchdog = () => {
|
||
clearAutoJoinWatchdog()
|
||
autoJoinWatchdog = setTimeout(() => {
|
||
if (joining.value && !autoJoinError.value) {
|
||
autoJoinError.value = '连接会议服务器超时(15s)。可能是网络受限或会议服务(WebSocket/媒体通道)暂不可达,请重试或检查网络。'
|
||
}
|
||
}, 15000)
|
||
}
|
||
const clearAutoJoinWatchdog = () => {
|
||
if (autoJoinWatchdog) { clearTimeout(autoJoinWatchdog); autoJoinWatchdog = null }
|
||
}
|
||
|
||
const retryAutoJoin = () => {
|
||
autoJoinError.value = ''
|
||
joining.value = false
|
||
startAutoJoinWatchdog()
|
||
onJoin({ skipMedia: true })
|
||
}
|
||
|
||
const exitAutoJoin = () => {
|
||
clearAutoJoinWatchdog()
|
||
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
|
||
minimizeCloudLawMeeting(cloudLawCallId.value)
|
||
return
|
||
}
|
||
onCancel()
|
||
}
|
||
|
||
const onJoin = async (options = {}) => {
|
||
if (joining.value || !canJoin.value) return
|
||
joining.value = true
|
||
const skipMedia = options.skipMedia === true
|
||
const shouldStartAudio = !skipMedia && mediaPrefs.startAudio
|
||
const shouldStartVideo = !skipMedia && mediaPrefs.startVideo
|
||
|
||
meetingStore.devicePreview = {
|
||
audioDeviceId: selectedAudioId.value,
|
||
videoDeviceId: selectedVideoId.value,
|
||
speakerDeviceId: selectedSpeakerId.value,
|
||
displayName: displayName.value,
|
||
startAudio: shouldStartAudio,
|
||
startVideo: shouldStartVideo
|
||
}
|
||
|
||
// iOS Safari/Chrome 上 preview 释放摄像头后 room 再次 getUserMedia 常触发 NotReadableError,
|
||
// 改为把预览页已申请的 track 直接复用给 mediasoup produce。
|
||
// 此处只停 AudioContext 分析器,不 stop previewStream,track 所有权随之转给 MediaEngine.produce。
|
||
stopAudioMeter()
|
||
|
||
const rawAudioTrack = previewStream ? previewStream.getAudioTracks()[0] || null : null
|
||
const rawVideoTrack = previewStream ? previewStream.getVideoTracks()[0] || null : null
|
||
// 若用户关掉了"默认开麦/默认开摄像头",对应 track 不会被 mediasoup 接手,立即 stop 避免硬件泄漏
|
||
if (rawAudioTrack && !shouldStartAudio) {
|
||
try { rawAudioTrack.stop() } catch {}
|
||
}
|
||
if (rawVideoTrack && !shouldStartVideo) {
|
||
try { rawVideoTrack.stop() } catch {}
|
||
}
|
||
const audioTrack = shouldStartAudio ? rawAudioTrack : null
|
||
const videoTrack = shouldStartVideo ? rawVideoTrack : null
|
||
// previewStream 对象本身不再需要持有,tracks 已取出交给 MediaEngine;
|
||
// 避免在后续 onUnload 中误调 stopPreviewStream 把 track 给 stop 掉。
|
||
previewStream = null
|
||
|
||
const prefs = {
|
||
startAudio: shouldStartAudio,
|
||
startVideo: shouldStartVideo,
|
||
audioDeviceId: selectedAudioId.value,
|
||
videoDeviceId: selectedVideoId.value,
|
||
audioTrack,
|
||
videoTrack
|
||
}
|
||
|
||
try {
|
||
if (mode.value === 'create') {
|
||
const payload = meetingStore.draftCreatePayload
|
||
if (!payload) {
|
||
throw new Error('未找到创建会议的表单草稿,请返回创建页重新填写')
|
||
}
|
||
await meetingStore.createAndEnter(payload, prefs)
|
||
meetingStore.draftCreatePayload = null
|
||
} else {
|
||
await meetingStore.joinAndEnter(joinCode.value, joinPassword.value, prefs)
|
||
}
|
||
const roomCode = meetingStore.currentRoom?.room_code
|
||
const roleQ = cloudLawRole.value ? `&cloudLawRole=${encodeURIComponent(cloudLawRole.value)}` : ''
|
||
const callQ = cloudLawCallId.value ? `&callId=${encodeURIComponent(cloudLawCallId.value)}` : ''
|
||
uni.redirectTo({ url: `/pages/meeting/room${roomCode ? `?code=${roomCode}${roleQ}${callQ}` : `${roleQ || callQ ? `?${(roleQ + callQ).replace(/^&/, '')}` : ''}`}` })
|
||
} catch (err) {
|
||
const msg = err?.message || JSON.stringify(err)
|
||
// autoJoin 模式下把错误显式呈现在页面(toast 一闪而过,真机很难看清)
|
||
if (autoJoin.value) {
|
||
autoJoinError.value = `加入失败:${msg}`
|
||
}
|
||
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
||
console.error('[Preview] 加入会议失败', err)
|
||
// 入会失败:track 尚未转给 mediasoup 或刚拿到就失败,显式 stop 避免摄像头泄漏
|
||
try { audioTrack && audioTrack.stop && audioTrack.stop() } catch {}
|
||
try { videoTrack && videoTrack.stop && videoTrack.stop() } catch {}
|
||
} finally {
|
||
clearAutoJoinWatchdog()
|
||
joining.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 生命周期 ====================
|
||
|
||
onLoad(async (query) => {
|
||
try {
|
||
mode.value = query?.mode === 'join' ? 'join' : 'create'
|
||
joinCode.value = query?.code || ''
|
||
cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
|
||
cloudLawCallId.value = query?.callId || query?.call_id || ''
|
||
autoJoin.value = query?.autoJoin === '1' || query?.autoJoin === 'true' || query?.autoJoin === true
|
||
// #ifdef H5
|
||
if (String(cloudLawRole.value).toLowerCase() === 'law') {
|
||
try {
|
||
window.sessionStorage.setItem('echo_cloud_law_meeting_role', 'law')
|
||
} catch (e) {}
|
||
}
|
||
// #endif
|
||
console.log('[Preview] onLoad params:', {
|
||
query,
|
||
mode: mode.value,
|
||
joinCode: joinCode.value,
|
||
autoJoin: autoJoin.value,
|
||
userId: userStore.userInfo?.id || userStore.userInfo?.user_id || ''
|
||
})
|
||
// P0-4:密码不再从 URL query 读取,改为从 meetingStore.draftJoinPayload 一次性消费
|
||
// 防止 URL 历史 / DevTools 留痕;读取后立即清空草稿避免二次写回
|
||
if (mode.value === 'join') {
|
||
const draft = meetingStore.draftJoinPayload
|
||
if (draft && (!joinCode.value || draft.code === joinCode.value)) {
|
||
joinPassword.value = draft.password || ''
|
||
meetingStore.draftJoinPayload = null
|
||
} else {
|
||
joinPassword.value = ''
|
||
}
|
||
}
|
||
|
||
if (autoJoin.value && canJoin.value) {
|
||
await nextTick()
|
||
console.log('[Preview] autoJoin start:', {
|
||
joinCode: joinCode.value,
|
||
hasPermission: hasPermission.value,
|
||
permissionError: permissionError.value
|
||
})
|
||
startAutoJoinWatchdog()
|
||
onJoin({ skipMedia: true })
|
||
return
|
||
}
|
||
initializing.value = false
|
||
// 先请求权限并预览,默认拿到第一个设备
|
||
await startPreview()
|
||
// 权限到手后枚举设备拿到 label
|
||
await loadDevices()
|
||
// 若此时已选中某个设备,重新启动预览确保走明确 deviceId(避免浏览器默认设备切换)
|
||
if (hasPermission.value && (selectedVideoId.value || selectedAudioId.value)) {
|
||
await startPreview()
|
||
}
|
||
} catch (err) {
|
||
initializing.value = false
|
||
throw err
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (changeDebounceTimer) {
|
||
clearTimeout(changeDebounceTimer)
|
||
changeDebounceTimer = null
|
||
}
|
||
clearAutoJoinWatchdog()
|
||
stopPreviewStream()
|
||
stopAudioMeter()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.auto-join-page {
|
||
min-height: 100vh;
|
||
background: #F8FAFC;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 48rpx;
|
||
}
|
||
.auto-join-title {
|
||
font-size: 30rpx;
|
||
color: #475569;
|
||
}
|
||
.auto-join-error-title {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: #DC2626;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
.auto-join-error-msg {
|
||
font-size: 26rpx;
|
||
line-height: 40rpx;
|
||
color: #475569;
|
||
text-align: center;
|
||
margin-bottom: 40rpx;
|
||
word-break: break-all;
|
||
}
|
||
.auto-join-actions {
|
||
display: flex;
|
||
gap: 24rpx;
|
||
}
|
||
.aj-btn {
|
||
min-width: 200rpx;
|
||
height: 80rpx;
|
||
line-height: 80rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 28rpx;
|
||
text-align: center;
|
||
border: none;
|
||
}
|
||
.aj-btn-primary {
|
||
background: #3B82F6;
|
||
color: #FFFFFF;
|
||
}
|
||
.aj-btn-secondary {
|
||
background: #E2E8F0;
|
||
color: #334155;
|
||
}
|
||
.page { min-height: 100vh; background: #F8FAFC; padding: 24rpx; }
|
||
.container { max-width: 960rpx; margin: 0 auto; }
|
||
.title { font-size: 40rpx; font-weight: 600; color: #0F172A; display: block; margin: 24rpx 0 8rpx; }
|
||
.subtitle { font-size: 26rpx; color: #64748B; display: block; margin-bottom: 24rpx; }
|
||
|
||
.content {
|
||
display: flex;
|
||
gap: 24rpx;
|
||
flex-wrap: wrap;
|
||
}
|
||
.preview-box {
|
||
flex: 1.4 1 400rpx;
|
||
min-width: 360rpx;
|
||
background: #FFFFFF;
|
||
border-radius: 16rpx;
|
||
padding: 20rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
.video-slot {
|
||
width: 100%;
|
||
aspect-ratio: 4 / 3;
|
||
min-height: 360rpx;
|
||
background: #0B1220;
|
||
border-radius: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
.preview-footer { display: flex; flex-direction: column; gap: 8rpx; }
|
||
.volume-wrap { display: flex; flex-direction: column; gap: 6rpx; }
|
||
.volume-label { font-size: 22rpx; color: #64748B; }
|
||
.volume-bar {
|
||
width: 100%;
|
||
height: 10rpx;
|
||
background: #E2E8F0;
|
||
border-radius: 6rpx;
|
||
overflow: hidden;
|
||
}
|
||
.volume-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #10B981 0%, #3B82F6 60%, #F59E0B 90%);
|
||
transition: width 0.08s linear;
|
||
}
|
||
.error { color: #EF4444; font-size: 22rpx; }
|
||
|
||
.controls {
|
||
flex: 1 1 300rpx;
|
||
min-width: 280rpx;
|
||
background: #FFFFFF;
|
||
border-radius: 16rpx;
|
||
padding: 32rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
|
||
}
|
||
.field { margin-bottom: 24rpx; }
|
||
.field:last-child { margin-bottom: 0; }
|
||
.label { font-size: 28rpx; color: #1E293B; font-weight: 500; display: block; margin-bottom: 10rpx; }
|
||
.picker, .input {
|
||
width: 100%;
|
||
padding: 18rpx 24rpx;
|
||
background: #F1F5F9;
|
||
border-radius: 12rpx;
|
||
font-size: 28rpx;
|
||
color: #0F172A;
|
||
border: 1px solid transparent;
|
||
box-sizing: border-box;
|
||
}
|
||
.picker {
|
||
min-height: 68rpx;
|
||
line-height: 32rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.switch-label { flex: 1; }
|
||
|
||
.actions { display: flex; gap: 24rpx; margin-top: 32rpx; }
|
||
.btn {
|
||
flex: 1;
|
||
padding: 24rpx 0;
|
||
border-radius: 12rpx;
|
||
font-size: 30rpx;
|
||
font-weight: 500;
|
||
border: none;
|
||
}
|
||
.btn-secondary { background: #F1F5F9; color: #475569; }
|
||
.btn-primary { background: #3B82F6; color: #FFFFFF; }
|
||
.btn-primary[disabled], .btn-secondary[disabled] { opacity: 0.6; }
|
||
</style>
|