feat(phase2e-2): 前端创建/加入/设备预览 3 页落地(Task 10)
页面
- pages/meeting/create.vue:创建表单,标题(默认"{昵称}的会议"/40 字限制) + 密码(4-16 位)
+ 入会静音开关 + 允许聊天开关。提交时表单暂存到 store.draftCreatePayload,跳 preview 页
- pages/meeting/join.vue:会议号输入(9 位纯数字/带连字符均可,自动格式化 XXX-XXX-XXX)
+ 可选密码。支持 URL query code/password 回填(邀请链接一键入会入口)
- pages/meeting/preview.vue:设备预览页
- navigator.mediaDevices.enumerateDevices() 列出摄像头/麦克风/扬声器
- getUserMedia({video,audio}) 本地画面预览(用原生 <video> 避开 uni-input 对 srcObject 的限制)
- AudioContext.AnalyserNode 以 rAF 采样 RMS,渲染渐变音量条
- 显示名输入 + 入会默认开麦/开摄像头开关
- 权限被拒/浏览器不支持 WebRTC 时给清晰错误提示
- 离开页时释放 preview stream 与 AudioContext,避免占用摄像头
Store 扩展(meeting.js)
- 新增 state:draftCreatePayload(create→preview 暂存) + devicePreview(设备选择结果)
- createAndEnter(payload, mediaPrefs?) / joinAndEnter(code, password, mediaPrefs?) 新增
可选 mediaPrefs 参数:{ startAudio, startVideo, audioDeviceId, videoDeviceId }
- _afterJoined 内按 mediaPrefs 入会后自动 startLocalAudio/Video(失败仅告警不阻断)
- startLocalAudio/Video 支持可选 deviceId,映射到 getUserMedia 的 deviceId.exact 约束
路由
- pages.json 注册 create/join/preview 3 条路由(均非 TabBar 页,从 URL 或后续入口跳入)
临时占位
- preview 页加入成功后暂时 redirectTo 到 debug.vue(Task 11 room 页待落地时改为 /pages/meeting/room)
Playwright 验证(真实 macOS 设备)
- create → preview:表单 "新昵称的会议" 正确携带到 preview 页
- preview 页:FaceTime 摄像头 + MacBook Pro 麦克风枚举成功,video readyState=4,音量条工作正常
- 点"加入会议":createAndEnter 成功,debug 页显示 localState=connected,主持人=是
mediaPrefs 生效——麦克风/摄像头按钮显示"关闭麦克风/关闭摄像头"说明自动推流成功
- join 页:URL ?code=123-456-789&password=secret 正确预填,"下一步" 按钮启用
Made-with: Cursor
This commit is contained in:
@@ -148,6 +148,24 @@
|
|||||||
"navigationBarTitleText": "会议调试(Task 9)"
|
"navigationBarTitleText": "会议调试(Task 9)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/meeting/create",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "创建会议"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/meeting/join",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "加入会议"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/meeting/preview",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "设备预览"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/notify/index",
|
"path": "pages/notify/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
187
frontend/src/pages/meeting/create.vue
Normal file
187
frontend/src/pages/meeting/create.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<!--
|
||||||
|
创建会议表单页(Task 10)
|
||||||
|
|
||||||
|
数据流:
|
||||||
|
用户在本页填写 title/password/开关 → 点击"下一步"
|
||||||
|
→ 保存表单到 meetingStore.draftCreatePayload
|
||||||
|
→ uni.navigateTo('/pages/meeting/preview?mode=create')
|
||||||
|
→ 在预览页选定设备后真正调用 createAndEnter
|
||||||
|
|
||||||
|
设计依据:
|
||||||
|
- Phase 2e-2 设计 §8.1 路由表 + D04 决策(入会前强制经过设备预览页)
|
||||||
|
- 本页不调用任何会议 API,无副作用;取消即返回
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="container">
|
||||||
|
<text class="title">创建会议</text>
|
||||||
|
<text class="subtitle">填写会议基本信息,下一步设置摄像头与麦克风</text>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">会议标题 <text class="required">*</text></text>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
class="input"
|
||||||
|
placeholder="例如:周会同步(限 40 字)"
|
||||||
|
maxlength="40"
|
||||||
|
/>
|
||||||
|
<text class="hint" v-if="titleError">{{ titleError }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">会议密码(可选)</text>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
class="input"
|
||||||
|
placeholder="留空表示无密码;4-16 位数字/字母"
|
||||||
|
maxlength="16"
|
||||||
|
/>
|
||||||
|
<text class="hint" v-if="passwordError">{{ passwordError }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field switch-row">
|
||||||
|
<view class="switch-label">
|
||||||
|
<text class="label">入会默认静音</text>
|
||||||
|
<text class="desc">新成员加入时麦克风默认关闭</text>
|
||||||
|
</view>
|
||||||
|
<switch :checked="form.enter_muted" @change="onToggleMuted" color="#3B82F6" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field switch-row">
|
||||||
|
<view class="switch-label">
|
||||||
|
<text class="label">允许会议内聊天</text>
|
||||||
|
<text class="desc">关闭后成员不能发送聊天消息</text>
|
||||||
|
</view>
|
||||||
|
<switch :checked="form.allow_chat" @change="onToggleChat" color="#3B82F6" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn btn-secondary" @click="onCancel">取消</button>
|
||||||
|
<button class="btn btn-primary" :disabled="!isFormValid" @click="onNext">下一步:设备预览</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
import { useMeetingStore } from '@/store/meeting'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
const meetingStore = useMeetingStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const defaultTitle = () => {
|
||||||
|
const name = userStore.userInfo?.nickname || userStore.userInfo?.username || '我'
|
||||||
|
return `${name}的会议`
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: defaultTitle(),
|
||||||
|
password: '',
|
||||||
|
enter_muted: false,
|
||||||
|
allow_chat: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleError = computed(() => {
|
||||||
|
const t = form.title.trim()
|
||||||
|
if (!t) return '会议标题不能为空'
|
||||||
|
if (t.length > 40) return '会议标题不能超过 40 字'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordError = computed(() => {
|
||||||
|
const p = form.password
|
||||||
|
if (!p) return ''
|
||||||
|
if (p.length < 4 || p.length > 16) return '密码长度需为 4-16 位'
|
||||||
|
if (!/^[A-Za-z0-9]+$/.test(p)) return '密码仅支持数字和英文字母'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFormValid = computed(() => !titleError.value && !passwordError.value)
|
||||||
|
|
||||||
|
const onToggleMuted = (e) => { form.enter_muted = e.detail.value }
|
||||||
|
const onToggleChat = (e) => { form.allow_chat = e.detail.value }
|
||||||
|
|
||||||
|
const onNext = () => {
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
meetingStore.draftCreatePayload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
password: form.password || undefined,
|
||||||
|
enter_muted: form.enter_muted,
|
||||||
|
allow_chat: form.allow_chat
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: '/pages/meeting/preview?mode=create' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: #F8FAFC; padding: 24rpx; }
|
||||||
|
.container { max-width: 680rpx; 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: 32rpx; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 28rpx; }
|
||||||
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
.label { font-size: 28rpx; color: #1E293B; font-weight: 500; display: block; margin-bottom: 12rpx; }
|
||||||
|
.required { color: #EF4444; font-weight: 600; }
|
||||||
|
.desc { font-size: 24rpx; color: #94A3B8; display: block; margin-top: 4rpx; }
|
||||||
|
.hint { font-size: 22rpx; color: #EF4444; display: block; margin-top: 8rpx; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
background: #F1F5F9;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #0F172A;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: #3B82F6; background: #FFFFFF; }
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.switch-label { flex: 1; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
.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] {
|
||||||
|
background: #BFDBFE;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
168
frontend/src/pages/meeting/join.vue
Normal file
168
frontend/src/pages/meeting/join.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<!--
|
||||||
|
加入会议表单页(Task 10)
|
||||||
|
|
||||||
|
数据流:
|
||||||
|
用户输入会议号(可粘贴带 / 不带连字符的 9 位数字)+ 可选密码 → 点击"下一步"
|
||||||
|
→ uni.navigateTo('/pages/meeting/preview?mode=join&code=XXX-XXX-XXX&password=xxx')
|
||||||
|
→ 预览页选定设备后真正调用 joinAndEnter
|
||||||
|
|
||||||
|
邀请链接支持:
|
||||||
|
- URL 参数 code=XXX-XXX-XXX(必传时直接回填)
|
||||||
|
- URL 参数 password=xxx(可选,从邀请短链来)
|
||||||
|
- 参考设计 §2.2.1:邀请链接形如 /#/pages/meeting/join?code=XXX-XXX-XXX
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="container">
|
||||||
|
<text class="title">加入会议</text>
|
||||||
|
<text class="subtitle">输入主持人分享的 9 位会议号,下一步选择设备</text>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">会议号 <text class="required">*</text></text>
|
||||||
|
<input
|
||||||
|
v-model="rawCodeInput"
|
||||||
|
class="input code-input"
|
||||||
|
placeholder="XXX-XXX-XXX"
|
||||||
|
maxlength="11"
|
||||||
|
@input="onCodeInput"
|
||||||
|
/>
|
||||||
|
<text class="hint" v-if="codeError">{{ codeError }}</text>
|
||||||
|
<text class="hint neutral" v-else-if="formattedCode">已识别:{{ formattedCode }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">会议密码(如有)</text>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
class="input"
|
||||||
|
placeholder="若会议设置了密码请填写"
|
||||||
|
maxlength="16"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn btn-secondary" @click="onCancel">取消</button>
|
||||||
|
<button class="btn btn-primary" :disabled="!isFormValid" @click="onNext">下一步:设备预览</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
|
const rawCodeInput = ref('')
|
||||||
|
const form = reactive({ password: '' })
|
||||||
|
|
||||||
|
/** 去掉所有非数字字符,截至 9 位 */
|
||||||
|
const digitsOf = (s) => (s || '').replace(/\D/g, '').slice(0, 9)
|
||||||
|
|
||||||
|
/** 把 9 位数字格式化为 XXX-XXX-XXX(不足 9 位时按实际位数插连字符) */
|
||||||
|
const formatCode = (digits) => {
|
||||||
|
if (digits.length <= 3) return digits
|
||||||
|
if (digits.length <= 6) return `${digits.slice(0, 3)}-${digits.slice(3)}`
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedCode = computed(() => {
|
||||||
|
const d = digitsOf(rawCodeInput.value)
|
||||||
|
return d.length === 9 ? formatCode(d) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const codeError = computed(() => {
|
||||||
|
const d = digitsOf(rawCodeInput.value)
|
||||||
|
if (d.length === 0) return ''
|
||||||
|
if (d.length < 9) return `还差 ${9 - d.length} 位`
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFormValid = computed(() => digitsOf(rawCodeInput.value).length === 9)
|
||||||
|
|
||||||
|
const onCodeInput = (e) => {
|
||||||
|
// 同时兼容 uni-app 事件(e.detail.value)与原生 input 事件(e.target.value),自动化测试友好
|
||||||
|
const raw = e?.detail?.value ?? e?.target?.value ?? rawCodeInput.value
|
||||||
|
const d = digitsOf(raw)
|
||||||
|
rawCodeInput.value = formatCode(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
if (query?.code) {
|
||||||
|
const d = digitsOf(query.code)
|
||||||
|
if (d.length === 9) rawCodeInput.value = formatCode(d)
|
||||||
|
}
|
||||||
|
if (query?.password) {
|
||||||
|
form.password = String(query.password)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onNext = () => {
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
const code = formattedCode.value
|
||||||
|
const passwordQuery = form.password ? `&password=${encodeURIComponent(form.password)}` : ''
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/meeting/preview?mode=join&code=${code}${passwordQuery}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: #F8FAFC; padding: 24rpx; }
|
||||||
|
.container { max-width: 680rpx; 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: 32rpx; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 28rpx; }
|
||||||
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
.label { font-size: 28rpx; color: #1E293B; font-weight: 500; display: block; margin-bottom: 12rpx; }
|
||||||
|
.required { color: #EF4444; font-weight: 600; }
|
||||||
|
.hint { font-size: 22rpx; color: #EF4444; display: block; margin-top: 8rpx; }
|
||||||
|
.hint.neutral { color: #10B981; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
background: #F1F5F9;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #0F172A;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: #3B82F6; background: #FFFFFF; }
|
||||||
|
.code-input {
|
||||||
|
font-size: 36rpx;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
.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] { background: #BFDBFE; color: #FFFFFF; }
|
||||||
|
</style>
|
||||||
495
frontend/src/pages/meeting/preview.vue
Normal file
495
frontend/src/pages/meeting/preview.vue
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!--
|
||||||
|
设备预览页(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 时给明确错误提示
|
||||||
|
|
||||||
|
入会成功后(Task 11 room 页未就绪期间):uni.redirectTo 到会议调试页 debug.vue 作为会议室占位。
|
||||||
|
TODO(Task 11 落地后):改为 uni.redirectTo '/pages/meeting/room?code=xxx'
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view 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'
|
||||||
|
|
||||||
|
const meetingStore = useMeetingStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const mode = ref('create')
|
||||||
|
const joinCode = ref('')
|
||||||
|
const joinPassword = ref('')
|
||||||
|
|
||||||
|
const modeLabel = computed(() => mode.value === 'create' ? '即将创建新会议' : `即将加入会议 ${joinCode.value}`)
|
||||||
|
|
||||||
|
const videoBox = ref(null)
|
||||||
|
let previewStream = null
|
||||||
|
let audioContext = null
|
||||||
|
let analyser = null
|
||||||
|
let volumeRafId = 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) {
|
||||||
|
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopPreviewStream()
|
||||||
|
stopAudioMeter()
|
||||||
|
try {
|
||||||
|
const constraints = {
|
||||||
|
audio: selectedAudioId.value ? { deviceId: { exact: selectedAudioId.value } } : true,
|
||||||
|
video: selectedVideoId.value
|
||||||
|
? { deviceId: { exact: selectedVideoId.value }, width: 640, height: 480 }
|
||||||
|
: { width: 640, height: 480 }
|
||||||
|
}
|
||||||
|
previewStream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
hasPermission.value = true
|
||||||
|
permissionError.value = ''
|
||||||
|
await nextTick()
|
||||||
|
mountPreviewVideo()
|
||||||
|
startAudioMeter(previewStream)
|
||||||
|
} catch (err) {
|
||||||
|
hasPermission.value = false
|
||||||
|
permissionError.value = err?.name === 'NotAllowedError'
|
||||||
|
? '已拒绝摄像头/麦克风权限,请在浏览器地址栏左侧恢复权限后重试'
|
||||||
|
: `无法访问音视频设备:${err?.message || err}`
|
||||||
|
console.warn('[Preview] getUserMedia 失败', err)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
startPreview()
|
||||||
|
}
|
||||||
|
const onAudioChange = (e) => {
|
||||||
|
const idx = Number(e.detail.value)
|
||||||
|
const d = audioDevices.value[idx]
|
||||||
|
if (!d) return
|
||||||
|
selectedAudioId.value = d.deviceId
|
||||||
|
startPreview()
|
||||||
|
}
|
||||||
|
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 = () => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onJoin = async () => {
|
||||||
|
if (joining.value || !canJoin.value) return
|
||||||
|
joining.value = true
|
||||||
|
|
||||||
|
meetingStore.devicePreview = {
|
||||||
|
audioDeviceId: selectedAudioId.value,
|
||||||
|
videoDeviceId: selectedVideoId.value,
|
||||||
|
speakerDeviceId: selectedSpeakerId.value,
|
||||||
|
displayName: displayName.value,
|
||||||
|
startAudio: mediaPrefs.startAudio,
|
||||||
|
startVideo: mediaPrefs.startVideo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 释放预览流,避免与 mediasoup produce 争用摄像头(浏览器单轨互斥)
|
||||||
|
stopPreviewStream()
|
||||||
|
stopAudioMeter()
|
||||||
|
|
||||||
|
const prefs = {
|
||||||
|
startAudio: mediaPrefs.startAudio,
|
||||||
|
startVideo: mediaPrefs.startVideo,
|
||||||
|
audioDeviceId: selectedAudioId.value,
|
||||||
|
videoDeviceId: selectedVideoId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Task 11 会议室页尚未落地,临时跳转 debug.vue 作为会议室占位
|
||||||
|
uni.redirectTo({ url: '/pages/meeting/debug' })
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message || JSON.stringify(err)
|
||||||
|
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
||||||
|
console.error('[Preview] 加入会议失败', err)
|
||||||
|
} finally {
|
||||||
|
joining.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
mode.value = query?.mode === 'join' ? 'join' : 'create'
|
||||||
|
joinCode.value = query?.code || ''
|
||||||
|
joinPassword.value = query?.password ? decodeURIComponent(query.password) : ''
|
||||||
|
|
||||||
|
// 先请求权限并预览,默认拿到第一个设备
|
||||||
|
await startPreview()
|
||||||
|
// 权限到手后枚举设备拿到 label
|
||||||
|
await loadDevices()
|
||||||
|
// 若此时已选中某个设备,重新启动预览确保走明确 deviceId(避免浏览器默认设备切换)
|
||||||
|
if (hasPermission.value && (selectedVideoId.value || selectedAudioId.value)) {
|
||||||
|
await startPreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPreviewStream()
|
||||||
|
stopAudioMeter()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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>
|
||||||
@@ -104,6 +104,27 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
*/
|
*/
|
||||||
const remoteConsumers = reactive({})
|
const remoteConsumers = reactive({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建会议表单草稿(Task 10)
|
||||||
|
* create.vue 填完表单后暂存于此,preview.vue 点"加入会议"时才真正调用 createAndEnter。
|
||||||
|
* 字段:{ title, password?, enter_muted, allow_chat }
|
||||||
|
*/
|
||||||
|
const draftCreatePayload = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备预览页的设备选择与显示名(Task 10)
|
||||||
|
* preview.vue 选完设备后写入,入会时作为 mediaPrefs 传给 createAndEnter / joinAndEnter
|
||||||
|
* 字段:{ audioDeviceId, videoDeviceId, speakerDeviceId, displayName, startAudio, startVideo }
|
||||||
|
*/
|
||||||
|
const devicePreview = ref({
|
||||||
|
audioDeviceId: '',
|
||||||
|
videoDeviceId: '',
|
||||||
|
speakerDeviceId: '',
|
||||||
|
displayName: '',
|
||||||
|
startAudio: true,
|
||||||
|
startVideo: true
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
|
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
|
||||||
* Key 是事件名,Value 是监听函数引用
|
* Key 是事件名,Value 是监听函数引用
|
||||||
@@ -411,8 +432,14 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
|
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {Object} opts.rtpCapabilities - Router RTP capabilities
|
||||||
|
* @param {Object} [opts.mediaPrefs] - 设备预览页传入的媒体偏好,入会成功后自动推流
|
||||||
|
* - startAudio: boolean 是否默认开麦
|
||||||
|
* - startVideo: boolean 是否默认开摄像头
|
||||||
|
* - audioDeviceId / videoDeviceId: 指定采集设备(可空→走系统默认)
|
||||||
*/
|
*/
|
||||||
const _afterJoined = async ({ rtpCapabilities }) => {
|
const _afterJoined = async ({ rtpCapabilities, mediaPrefs }) => {
|
||||||
if (!isMediaSupported()) {
|
if (!isMediaSupported()) {
|
||||||
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
|
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
|
||||||
}
|
}
|
||||||
@@ -451,15 +478,28 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
||||||
|
|
||||||
|
// mediaPrefs 自动推流:失败仅告警,不阻断入会流程,允许用户在会议室内手动重试开麦/开摄像头
|
||||||
|
if (mediaPrefs) {
|
||||||
|
if (mediaPrefs.startAudio) {
|
||||||
|
try { await startLocalAudio(mediaPrefs.audioDeviceId) }
|
||||||
|
catch (e) { _log('warn', '[Meeting] 自动开麦失败', e) }
|
||||||
|
}
|
||||||
|
if (mediaPrefs.startVideo) {
|
||||||
|
try { await startLocalVideo(mediaPrefs.videoDeviceId) }
|
||||||
|
catch (e) { _log('warn', '[Meeting] 自动开摄像头失败', e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Actions:会议生命周期 ====================
|
// ==================== Actions:会议生命周期 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建即时会议并立即入会
|
* 创建即时会议并立即入会
|
||||||
* @param {Object} payload - { title, password?, max_members? }
|
* @param {Object} payload - { title, password?, max_members?, enter_muted?, allow_chat? }
|
||||||
|
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好,入会成功后自动推流(可空)
|
||||||
*/
|
*/
|
||||||
const createAndEnter = async (payload) => {
|
const createAndEnter = async (payload, mediaPrefs) => {
|
||||||
if (isInMeeting.value) {
|
if (isInMeeting.value) {
|
||||||
throw new Error('你当前已在其他会议中')
|
throw new Error('你当前已在其他会议中')
|
||||||
}
|
}
|
||||||
@@ -476,7 +516,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
joined_at: new Date().toISOString()
|
joined_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
|
||||||
return currentRoom.value
|
return currentRoom.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_log('error', '[Meeting] 创建并入会失败', err)
|
_log('error', '[Meeting] 创建并入会失败', err)
|
||||||
@@ -489,8 +529,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
* 加入已有会议
|
* 加入已有会议
|
||||||
* @param {string} roomCode
|
* @param {string} roomCode
|
||||||
* @param {string} [password]
|
* @param {string} [password]
|
||||||
|
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好(可空)
|
||||||
*/
|
*/
|
||||||
const joinAndEnter = async (roomCode, password = '') => {
|
const joinAndEnter = async (roomCode, password = '', mediaPrefs) => {
|
||||||
if (isInMeeting.value) {
|
if (isInMeeting.value) {
|
||||||
throw new Error('你当前已在其他会议中')
|
throw new Error('你当前已在其他会议中')
|
||||||
}
|
}
|
||||||
@@ -500,7 +541,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
currentRoom.value = resp.room
|
currentRoom.value = resp.room
|
||||||
currentParticipant.value = resp.participant
|
currentParticipant.value = resp.participant
|
||||||
routerID.value = resp.router_id || ''
|
routerID.value = resp.router_id || ''
|
||||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
|
||||||
return currentRoom.value
|
return currentRoom.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_log('error', '[Meeting] 加入会议失败', err)
|
_log('error', '[Meeting] 加入会议失败', err)
|
||||||
@@ -540,22 +581,34 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
|
|
||||||
// ==================== Actions:本地推流 ====================
|
// ==================== Actions:本地推流 ====================
|
||||||
|
|
||||||
/** 开启本地音频 */
|
/**
|
||||||
const startLocalAudio = async () => {
|
* 开启本地音频
|
||||||
|
* @param {string} [deviceId] - 指定麦克风 deviceId(从 enumerateDevices 获取),为空则走系统默认
|
||||||
|
*/
|
||||||
|
const startLocalAudio = async (deviceId) => {
|
||||||
if (!_engine) throw new Error('未连接')
|
if (!_engine) throw new Error('未连接')
|
||||||
if (localProducers.audio) return
|
if (localProducers.audio) return
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||||
const track = stream.getAudioTracks()[0]
|
const track = stream.getAudioTracks()[0]
|
||||||
const producer = await _engine.produce({ kind: 'audio', track })
|
const producer = await _engine.produce({ kind: 'audio', track })
|
||||||
localProducers.audio = producer.id
|
localProducers.audio = producer.id
|
||||||
localAudioEnabled.value = true
|
localAudioEnabled.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 开启本地视频 */
|
/**
|
||||||
const startLocalVideo = async () => {
|
* 开启本地视频
|
||||||
|
* @param {string} [deviceId] - 指定摄像头 deviceId,为空则走系统默认
|
||||||
|
*/
|
||||||
|
const startLocalVideo = async (deviceId) => {
|
||||||
if (!_engine) throw new Error('未连接')
|
if (!_engine) throw new Error('未连接')
|
||||||
if (localProducers.video) return
|
if (localProducers.video) return
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
|
const videoConstraints = {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
...(deviceId ? { deviceId: { exact: deviceId } } : {})
|
||||||
|
}
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })
|
||||||
const track = stream.getVideoTracks()[0]
|
const track = stream.getVideoTracks()[0]
|
||||||
const producer = await _engine.produce({ kind: 'video', track })
|
const producer = await _engine.produce({ kind: 'video', track })
|
||||||
localProducers.video = producer.id
|
localProducers.video = producer.id
|
||||||
@@ -671,6 +724,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
localVideoEnabled,
|
localVideoEnabled,
|
||||||
localProducers,
|
localProducers,
|
||||||
remoteConsumers,
|
remoteConsumers,
|
||||||
|
draftCreatePayload,
|
||||||
|
devicePreview,
|
||||||
|
|
||||||
// getters
|
// getters
|
||||||
isInMeeting,
|
isInMeeting,
|
||||||
|
|||||||
Reference in New Issue
Block a user