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:
@@ -104,6 +104,27 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
*/
|
||||
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 内的事件监听器集合(进房注册,离房注销)
|
||||
* Key 是事件名,Value 是监听函数引用
|
||||
@@ -411,8 +432,14 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
/**
|
||||
* 入会后的共同后置流程:加载 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()) {
|
||||
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
|
||||
}
|
||||
@@ -451,15 +478,28 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
|
||||
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:会议生命周期 ====================
|
||||
|
||||
/**
|
||||
* 创建即时会议并立即入会
|
||||
* @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) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
@@ -476,7 +516,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
is_active: true,
|
||||
joined_at: new Date().toISOString()
|
||||
}
|
||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
|
||||
return currentRoom.value
|
||||
} catch (err) {
|
||||
_log('error', '[Meeting] 创建并入会失败', err)
|
||||
@@ -489,8 +529,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
* 加入已有会议
|
||||
* @param {string} roomCode
|
||||
* @param {string} [password]
|
||||
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好(可空)
|
||||
*/
|
||||
const joinAndEnter = async (roomCode, password = '') => {
|
||||
const joinAndEnter = async (roomCode, password = '', mediaPrefs) => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
@@ -500,7 +541,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
currentRoom.value = resp.room
|
||||
currentParticipant.value = resp.participant
|
||||
routerID.value = resp.router_id || ''
|
||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
|
||||
return currentRoom.value
|
||||
} catch (err) {
|
||||
_log('error', '[Meeting] 加入会议失败', err)
|
||||
@@ -540,22 +581,34 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
// ==================== Actions:本地推流 ====================
|
||||
|
||||
/** 开启本地音频 */
|
||||
const startLocalAudio = async () => {
|
||||
/**
|
||||
* 开启本地音频
|
||||
* @param {string} [deviceId] - 指定麦克风 deviceId(从 enumerateDevices 获取),为空则走系统默认
|
||||
*/
|
||||
const startLocalAudio = async (deviceId) => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
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 producer = await _engine.produce({ kind: 'audio', track })
|
||||
localProducers.audio = producer.id
|
||||
localAudioEnabled.value = true
|
||||
}
|
||||
|
||||
/** 开启本地视频 */
|
||||
const startLocalVideo = async () => {
|
||||
/**
|
||||
* 开启本地视频
|
||||
* @param {string} [deviceId] - 指定摄像头 deviceId,为空则走系统默认
|
||||
*/
|
||||
const startLocalVideo = async (deviceId) => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
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 producer = await _engine.produce({ kind: 'video', track })
|
||||
localProducers.video = producer.id
|
||||
@@ -671,6 +724,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
localVideoEnabled,
|
||||
localProducers,
|
||||
remoteConsumers,
|
||||
draftCreatePayload,
|
||||
devicePreview,
|
||||
|
||||
// getters
|
||||
isInMeeting,
|
||||
|
||||
Reference in New Issue
Block a user