feat(meeting): Task 15 UI 打磨 + 主持人四件套 + 媒体层稳定性回归修复
- Task 15 UI:6 项原创特色(说话者流光 / 柔性网格 / 自视频浮窗 / 静音氛围色 / 入会滑入 / NetworkBadge 3 条波浪)+ 说话者双源探测(RTP audioLevel + WebAudio RMS)+ 主持人四件套(静音/开麦/转让/踢出) - 新增 SelfVideoFloat 浮窗组件 + 4 份 design-system 页面文档(home / preview / room / invite) - 媒体回归补丁(手工联调触发): * 后端 signal_service 在 OnRoomJoin 追加 pushExistingRoomState → 向新加入者补发历史 producers + 历史成员 audio/video 状态 * 新增 Redis Hash memberStateKey 持久化成员 audio/video enabled,OnMemberStateChanged 落盘、cleanupUserResources 清理 * 前端 _broadcastSelfState 在本地音视频开关末尾同步 state.changed;_afterJoined 先 getRoom 再 room.join 修复后入者成员状态时序;_onMemberStateChanged 占位兜底 * _cleanupRemoteProducer 改为按 producerId 精准清理(修复 "关音频误关视频") * _onRoomEnded + createAndEnter/joinAndEnter 强化 reset(修复重入会看不到自己画面) * mediasoup-client ensureSend/RecvTransport 引入 in-flight Promise 锁防并发重复建连 - 文档同步:CURRENT_STATUS.md / Task 15 plan / phase2e-2 implementation plan Made-with: Cursor
This commit is contained in:
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -37,6 +37,7 @@
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"sass": "^1.97.3",
|
||||
"vite": "5.2.8"
|
||||
@@ -4443,6 +4444,19 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
|
||||
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-legacy": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"sass": "^1.97.3",
|
||||
"vite": "5.2.8"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 按钮带 loading 态,禁用期间点击不派发事件
|
||||
-->
|
||||
<template>
|
||||
<view class="toolbar">
|
||||
<view class="toolbar" :class="{ 'toolbar-all-muted': allMuted }">
|
||||
<button class="btn" :class="{ active: audioEnabled, loading: audioLoading }" :disabled="audioLoading" @click="emitIf('mic-toggle')">
|
||||
<view class="icon">
|
||||
<svg v-if="audioEnabled" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -100,7 +100,9 @@ const props = defineProps({
|
||||
videoLoading: { type: Boolean, default: false },
|
||||
memberCount: { type: Number, default: 0 },
|
||||
unreadChatCount: { type: Number, default: 0 },
|
||||
allowChat: { type: Boolean, default: true }
|
||||
allowChat: { type: Boolean, default: true },
|
||||
/** Task 15 原创特色 4:全员静音氛围色 */
|
||||
allMuted: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'invite', 'members', 'chat', 'leave'])
|
||||
@@ -130,6 +132,13 @@ const emitIf = (name) => {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 210;
|
||||
transition: background 0.4s ease, border-top-color 0.4s ease;
|
||||
}
|
||||
/* Task 15 原创特色 4:全员静音氛围色
|
||||
冷蓝渐变暗示"全房间安静",触发条件由父组件 computed (allMuted) 控制 */
|
||||
.toolbar.toolbar-all-muted {
|
||||
background: linear-gradient(to top, rgba(30, 58, 138, 0.88), rgba(30, 64, 175, 0.78));
|
||||
border-top-color: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
@@ -68,6 +68,20 @@
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
<view v-if="openMenuUid === p.user_id" class="menu" @click.stop>
|
||||
<view
|
||||
v-if="p.audio_enabled !== false"
|
||||
class="menu-item"
|
||||
@click="onMute(p, true)"
|
||||
>
|
||||
<text>请他静音</text>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
class="menu-item"
|
||||
@click="onMute(p, false)"
|
||||
>
|
||||
<text>请他开麦</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="onTransfer(p)">
|
||||
<text>转让主持人</text>
|
||||
</view>
|
||||
@@ -98,7 +112,7 @@ const props = defineProps({
|
||||
displayNameMap: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'kick', 'transfer-host'])
|
||||
const emit = defineEmits(['close', 'kick', 'transfer-host', 'mute-member'])
|
||||
|
||||
const openMenuUid = ref(0)
|
||||
const show = ref(false)
|
||||
@@ -163,6 +177,12 @@ const onKick = (p) => {
|
||||
emit('kick', p.user_id)
|
||||
}
|
||||
|
||||
/** Task 15:主持人权限四件套第 4 件 —— 请他静音 / 请他开麦 */
|
||||
const onMute = (p, mute) => {
|
||||
openMenuUid.value = 0
|
||||
emit('mute-member', { userId: p.user_id, mute })
|
||||
}
|
||||
|
||||
const close = () => emit('close')
|
||||
const onMaskClick = () => close()
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
<!--
|
||||
网络质量徽标(Task 11)
|
||||
网络质量徽标(Task 11 / Task 15 波浪动效升级)
|
||||
|
||||
职责:
|
||||
- 渲染 4 格信号柱(类似手机信号图标),根据 level (0-4) 点亮
|
||||
- 可选展示文字标签(默认隐藏,hover/长按再显示)
|
||||
- 本组件不产生网络数据,由父页面在 Task 15 接入 mediasoup getStats 后喂入
|
||||
- Task 15 原创特色 6:使用 3 条横向 SVG 波浪曲线代替传统格子信号条
|
||||
- 根据 level (0-4) 动态点亮可见条数 + 颜色
|
||||
- 3 条波浪以相位差 0 / -0.3s / -0.6s 循环流动,形成"呼吸感"
|
||||
- 可选文字标签(title tooltip 保留,默认不显示 inline label)
|
||||
|
||||
level 语义:
|
||||
- 4 excellent / 3 good / 2 fair / 1 poor / 0 disconnected
|
||||
-->
|
||||
<template>
|
||||
<view class="badge" :class="levelClass" :title="labelText">
|
||||
<view v-for="n in 4" :key="n" class="bar" :class="{ on: n <= level }"
|
||||
:style="{ height: `${n * 20}%` }"
|
||||
></view>
|
||||
<view v-if="level <= 0" class="dc-icon" aria-label="已断开">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="12" x2="20" y2="12"></line>
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</view>
|
||||
<template v-else>
|
||||
<svg
|
||||
v-for="n in visibleLines"
|
||||
:key="n"
|
||||
class="wave"
|
||||
:class="`wave-${n}`"
|
||||
viewBox="0 0 24 12"
|
||||
width="24"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<path d="M 0 6 C 3 2, 6 10, 9 6 S 15 2, 18 6 S 22 10, 24 6" />
|
||||
</svg>
|
||||
</template>
|
||||
<text v-if="showLabel" class="label">{{ labelText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -30,6 +51,14 @@ const LEVEL_LABEL = ['已断开', '很差', '一般', '良好', '优秀']
|
||||
|
||||
const labelText = computed(() => LEVEL_LABEL[Math.max(0, Math.min(4, props.level))])
|
||||
|
||||
/** level → 可见条数(4=3 条 / 3=2 条 / 2/1=1 条,0 由 dc-icon 承接) */
|
||||
const visibleLines = computed(() => {
|
||||
if (props.level >= 4) return 3
|
||||
if (props.level >= 3) return 2
|
||||
if (props.level >= 1) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const levelClass = computed(() => {
|
||||
if (props.level >= 4) return 'lv-excellent'
|
||||
if (props.level >= 3) return 'lv-good'
|
||||
@@ -42,28 +71,42 @@ const levelClass = computed(() => {
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 3rpx;
|
||||
height: 28rpx;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 8rpx;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
height: 36rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 6rpx;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 2rpx;
|
||||
transition: background-color 0.15s ease;
|
||||
.wave {
|
||||
animation: wave-flow 1.2s linear infinite;
|
||||
flex-shrink: 0;
|
||||
/* 三条波浪相位差:通过 animation-delay 负值错开 */
|
||||
}
|
||||
.wave-1 { animation-delay: 0s; }
|
||||
.wave-2 { animation-delay: -0.3s; }
|
||||
.wave-3 { animation-delay: -0.6s; }
|
||||
|
||||
@keyframes wave-flow {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-6px); }
|
||||
}
|
||||
|
||||
.lv-excellent .bar.on { background: #10B981; }
|
||||
.lv-good .bar.on { background: #22C55E; }
|
||||
.lv-fair .bar.on { background: #F59E0B; }
|
||||
.lv-poor .bar.on { background: #EF4444; }
|
||||
.lv-off .bar { background: rgba(255, 255, 255, 0.2); }
|
||||
.lv-excellent { color: #10B981; }
|
||||
.lv-good { color: #22C55E; }
|
||||
.lv-fair { color: #F59E0B; }
|
||||
.lv-poor { color: #EF4444; }
|
||||
.lv-off { color: #EF4444; }
|
||||
|
||||
.dc-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: 8rpx;
|
||||
@@ -72,4 +115,9 @@ const levelClass = computed(() => {
|
||||
line-height: 1;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 可访问性:减少动效偏好下停止流动 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wave { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
325
frontend/src/components/meeting/SelfVideoFloat.vue
Normal file
325
frontend/src/components/meeting/SelfVideoFloat.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<!--
|
||||
自视频浮窗(Task 15 原创特色 3:四角吸附拖拽 + 图钉切回网格)
|
||||
|
||||
职责:
|
||||
- 桌面端恒为浮窗显示本地视频(默认右下角,可拖拽到任意位置,松手吸附到最近四角)
|
||||
- 提供图钉按钮切换 float ↔ grid 模式
|
||||
- 复用 VideoTile 的 track 挂载能力,只在外壳上做拖拽逻辑
|
||||
|
||||
设计要点:
|
||||
- 仅 H5 生效;非 H5 直接渲染为空
|
||||
- mousemove/touchmove 注册在 window 上,避免 iframe / 外部元素失焦
|
||||
- onBeforeUnmount 必须移除监听,防止泄漏
|
||||
- 吸附计算:记录容器边界矩形,释放时分别算到 4 角中心距离,取最小
|
||||
- z-index: 120(低于 Toolbar 210、低于 MemberPanel 200,高于 VideoGrid)
|
||||
-->
|
||||
<template>
|
||||
<view
|
||||
v-if="!hidden"
|
||||
ref="floatEl"
|
||||
class="float-shell"
|
||||
:class="{ dragging: isDragging }"
|
||||
:style="positionStyle"
|
||||
@mousedown="onDragStart"
|
||||
@touchstart="onDragStart"
|
||||
>
|
||||
<VideoTile
|
||||
:user-id="userId"
|
||||
:name="name"
|
||||
:is-local="true"
|
||||
:is-host="isHost"
|
||||
:audio-enabled="audioEnabled"
|
||||
:video-enabled="videoEnabled"
|
||||
:audio-track="audioTrack"
|
||||
:video-track="videoTrack"
|
||||
:is-speaking="isSpeaking"
|
||||
:compact="true"
|
||||
/>
|
||||
|
||||
<view class="pin-btn" title="收回网格" @click.stop="onPinClick" @mousedown.stop @touchstart.stop>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="17" x2="12" y2="22"></line>
|
||||
<path d="M5 17h14l-1.405-1.405A2 2 0 0 1 17 14.172V11a5 5 0 0 0-10 0v3.172a2 2 0 0 1-.595 1.423L5 17z"></path>
|
||||
</svg>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import VideoTile from '@/components/meeting/VideoTile.vue'
|
||||
|
||||
const props = defineProps({
|
||||
userId: { type: [Number, String], required: true },
|
||||
name: { type: String, default: '你' },
|
||||
isHost: { type: Boolean, default: false },
|
||||
audioEnabled: { type: Boolean, default: true },
|
||||
videoEnabled: { type: Boolean, default: true },
|
||||
audioTrack: { type: Object, default: null },
|
||||
videoTrack: { type: Object, default: null },
|
||||
isSpeaking: { type: Boolean, default: false },
|
||||
/** 父容器引用,用于计算吸附边界;不传则以 window 为边界 */
|
||||
container: { type: Object, default: null },
|
||||
/** 是否隐藏浮窗(例如图钉切换后) */
|
||||
hidden: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['pin-click'])
|
||||
|
||||
const floatEl = ref(null)
|
||||
|
||||
// ============ 位置状态 ============
|
||||
|
||||
/** 浮窗当前位置(绝对像素 / 容器坐标系);默认右下角 */
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
|
||||
/** 拖拽起始记录 */
|
||||
const dragStart = ref({ pointerX: 0, pointerY: 0, startX: 0, startY: 0 })
|
||||
|
||||
/** 浮窗尺寸:桌面端(宽屏 >= 900px)280x180px,移动端 / 窄屏 160x100px */
|
||||
const getDefaultSize = () => {
|
||||
// #ifdef H5
|
||||
const isDesktop = typeof window !== 'undefined' && window.innerWidth >= 900
|
||||
return isDesktop ? { width: 280, height: 180 } : { width: 160, height: 100 }
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return { width: 160, height: 100 }
|
||||
// #endif
|
||||
}
|
||||
const SIZE = getDefaultSize()
|
||||
const EDGE_MARGIN = 24
|
||||
|
||||
const positionStyle = computed(() => ({
|
||||
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
|
||||
width: `${SIZE.width}px`,
|
||||
height: `${SIZE.height}px`,
|
||||
transition: isDragging.value ? 'none' : 'transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1)'
|
||||
}))
|
||||
|
||||
// ============ 计算父容器边界 ============
|
||||
|
||||
const getBounds = () => {
|
||||
// #ifdef H5
|
||||
const el = resolveDom(props.container) || document.body
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: rect.width,
|
||||
bottom: rect.height
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return { left: 0, top: 0, right: 320, bottom: 240 }
|
||||
// #endif
|
||||
}
|
||||
|
||||
const resolveDom = (r) => {
|
||||
// #ifdef H5
|
||||
if (!r) return null
|
||||
if (r.$el) return r.$el
|
||||
if (r instanceof HTMLElement) return r
|
||||
// #endif
|
||||
return null
|
||||
}
|
||||
|
||||
/** 初始化位置:右下角 */
|
||||
const placeDefault = () => {
|
||||
const b = getBounds()
|
||||
position.value = {
|
||||
x: b.right - SIZE.width - EDGE_MARGIN,
|
||||
y: b.bottom - SIZE.height - EDGE_MARGIN
|
||||
}
|
||||
}
|
||||
|
||||
/** 松手后吸附到最近四角 */
|
||||
const snapToCorner = () => {
|
||||
const b = getBounds()
|
||||
const cx = position.value.x + SIZE.width / 2
|
||||
const cy = position.value.y + SIZE.height / 2
|
||||
const centers = [
|
||||
// 左上
|
||||
{ x: EDGE_MARGIN, y: EDGE_MARGIN },
|
||||
// 右上
|
||||
{ x: b.right - SIZE.width - EDGE_MARGIN, y: EDGE_MARGIN },
|
||||
// 左下
|
||||
{ x: EDGE_MARGIN, y: b.bottom - SIZE.height - EDGE_MARGIN },
|
||||
// 右下
|
||||
{ x: b.right - SIZE.width - EDGE_MARGIN, y: b.bottom - SIZE.height - EDGE_MARGIN }
|
||||
]
|
||||
let best = centers[3]
|
||||
let min = Infinity
|
||||
for (const c of centers) {
|
||||
const ccx = c.x + SIZE.width / 2
|
||||
const ccy = c.y + SIZE.height / 2
|
||||
const d = (cx - ccx) ** 2 + (cy - ccy) ** 2
|
||||
if (d < min) {
|
||||
min = d
|
||||
best = c
|
||||
}
|
||||
}
|
||||
position.value = best
|
||||
}
|
||||
|
||||
// ============ 拖拽交互 ============
|
||||
|
||||
const getPoint = (e) => {
|
||||
// #ifdef H5
|
||||
if (e.touches && e.touches.length > 0) {
|
||||
return { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
}
|
||||
return { x: e.clientX, y: e.clientY }
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return { x: 0, y: 0 }
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onDragStart = (e) => {
|
||||
// #ifdef H5
|
||||
if (e.target && e.target.closest && e.target.closest('.pin-btn')) return
|
||||
const pt = getPoint(e)
|
||||
dragStart.value = {
|
||||
pointerX: pt.x,
|
||||
pointerY: pt.y,
|
||||
startX: position.value.x,
|
||||
startY: position.value.y
|
||||
}
|
||||
isDragging.value = true
|
||||
window.addEventListener('mousemove', onDragMove, { passive: false })
|
||||
window.addEventListener('touchmove', onDragMove, { passive: false })
|
||||
window.addEventListener('mouseup', onDragEnd)
|
||||
window.addEventListener('touchend', onDragEnd)
|
||||
window.addEventListener('touchcancel', onDragEnd)
|
||||
// 禁用文字选中
|
||||
document.body.style.userSelect = 'none'
|
||||
e.preventDefault()
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onDragMove = (e) => {
|
||||
// #ifdef H5
|
||||
if (!isDragging.value) return
|
||||
const pt = getPoint(e)
|
||||
const dx = pt.x - dragStart.value.pointerX
|
||||
const dy = pt.y - dragStart.value.pointerY
|
||||
const b = getBounds()
|
||||
// Clamp 在 bounds 内,防止浮窗被拖出视频区域
|
||||
const nx = Math.max(0, Math.min(dragStart.value.startX + dx, b.right - SIZE.width))
|
||||
const ny = Math.max(0, Math.min(dragStart.value.startY + dy, b.bottom - SIZE.height))
|
||||
position.value = { x: nx, y: ny }
|
||||
if (e.cancelable) e.preventDefault()
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
// #ifdef H5
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
window.removeEventListener('mousemove', onDragMove)
|
||||
window.removeEventListener('touchmove', onDragMove)
|
||||
window.removeEventListener('mouseup', onDragEnd)
|
||||
window.removeEventListener('touchend', onDragEnd)
|
||||
window.removeEventListener('touchcancel', onDragEnd)
|
||||
document.body.style.userSelect = ''
|
||||
snapToCorner()
|
||||
// #endif
|
||||
}
|
||||
|
||||
// ============ 容器尺寸变化 ============
|
||||
|
||||
let _resizeObserver = null
|
||||
|
||||
const onContainerResize = () => {
|
||||
// 重新 clamp + 吸附到当前象限对应的四角
|
||||
const b = getBounds()
|
||||
if (position.value.x > b.right - SIZE.width - EDGE_MARGIN ||
|
||||
position.value.y > b.bottom - SIZE.height - EDGE_MARGIN) {
|
||||
snapToCorner()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.container, () => {
|
||||
// container 引用变化时重新定位
|
||||
nextTick(placeDefault)
|
||||
})
|
||||
|
||||
const onPinClick = () => {
|
||||
emit('pin-click')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(placeDefault)
|
||||
// #ifdef H5
|
||||
window.addEventListener('resize', onContainerResize)
|
||||
if (typeof ResizeObserver !== 'undefined' && props.container) {
|
||||
const el = resolveDom(props.container)
|
||||
if (el) {
|
||||
_resizeObserver = new ResizeObserver(onContainerResize)
|
||||
_resizeObserver.observe(el)
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// #ifdef H5
|
||||
window.removeEventListener('resize', onContainerResize)
|
||||
if (_resizeObserver) {
|
||||
_resizeObserver.disconnect()
|
||||
_resizeObserver = null
|
||||
}
|
||||
// 兜底:组件卸载时仍在拖拽中则强制清理 window 监听
|
||||
if (isDragging.value) {
|
||||
onDragEnd()
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.float-shell {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.45);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
/* transform 直接由 :style 驱动,不再设置 transition(由 isDragging 控制) */
|
||||
}
|
||||
.float-shell.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.pin-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.float-shell { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<!--
|
||||
会议视频网格(Task 11)
|
||||
会议视频网格(Task 11 / Task 15 柔性网格升级)
|
||||
|
||||
职责:
|
||||
- 根据成员数量自适应网格布局(1/2/3-4/5-6/7-9/10+)
|
||||
- 手机端竖排为主,桌面端以 16:9 单元格填充
|
||||
- 根据成员数量自适应网格布局(1/2/3/4-9/10+)
|
||||
- Task 15 原创特色 2:2 人左右 65/35 非等分;3 人「大+两个小」三角布局;4+ 人维持等分
|
||||
- 本地 tile 由上层 SelfVideoFloat 承接浮窗,当传入 tiles 仅含远端时 layout-* 数字即可对应视觉人数
|
||||
- 入会滑入动效:新成员 tile 挂载时向上位移 + 渐显(Task 15 原创特色 5)
|
||||
- 不关心业务数据,仅负责排版;视频块由 slot #tile 自定义渲染,保持数据与组件解耦
|
||||
-->
|
||||
<template>
|
||||
@@ -13,6 +15,7 @@
|
||||
v-for="(tile, idx) in tiles"
|
||||
:key="tile.key || tile.userId || idx"
|
||||
class="grid-cell"
|
||||
:class="cellClassFor(idx)"
|
||||
>
|
||||
<slot name="tile" :tile="tile" :index="idx" />
|
||||
</view>
|
||||
@@ -30,28 +33,72 @@ const props = defineProps({
|
||||
tiles: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
/** 根据成员数量计算列数 */
|
||||
const columns = computed(() => {
|
||||
/**
|
||||
* 布局档位:按成员数量决定非等分策略
|
||||
* - 1: 单屏
|
||||
* - 2: 左右 65/35
|
||||
* - 3: 左大 + 右两叠
|
||||
* - 4-9: 3×3 等分兜底
|
||||
* - 10+: sqrt 自适应
|
||||
*/
|
||||
const layoutClass = computed(() => {
|
||||
const n = props.tiles.length
|
||||
if (n <= 1) return 1
|
||||
if (n <= 4) return 2
|
||||
if (n <= 9) return 3
|
||||
if (n <= 16) return 4
|
||||
return Math.ceil(Math.sqrt(n))
|
||||
if (n === 0) return 'layout-empty'
|
||||
if (n === 1) return 'layout-1'
|
||||
if (n === 2) return 'layout-2-flex'
|
||||
if (n === 3) return 'layout-3-tri'
|
||||
if (n <= 9) return 'layout-grid-3'
|
||||
return 'layout-grid-sqrt'
|
||||
})
|
||||
|
||||
const rows = computed(() => {
|
||||
/**
|
||||
* grid-template 计算:
|
||||
* - layout-2-flex 用 grid-template-columns: 65fr 35fr(单行)
|
||||
* - layout-3-tri 用 2 列,左列 1 行、右列 2 行(cell 手动控制 span)
|
||||
* - layout-grid-3 同现有逻辑(3 列)
|
||||
* - layout-grid-sqrt 同现有逻辑(sqrt 列)
|
||||
*/
|
||||
const gridStyle = computed(() => {
|
||||
const n = props.tiles.length
|
||||
if (!n) return 1
|
||||
return Math.ceil(n / columns.value)
|
||||
if (n === 2) {
|
||||
return {
|
||||
'grid-template-columns': '65fr 35fr',
|
||||
'grid-template-rows': '1fr'
|
||||
}
|
||||
}
|
||||
if (n === 3) {
|
||||
return {
|
||||
'grid-template-columns': '60fr 40fr',
|
||||
'grid-template-rows': '1fr 1fr'
|
||||
}
|
||||
}
|
||||
if (n === 1) {
|
||||
return {
|
||||
'grid-template-columns': '1fr',
|
||||
'grid-template-rows': '1fr'
|
||||
}
|
||||
}
|
||||
if (n <= 9) {
|
||||
const cols = n <= 4 ? 2 : 3
|
||||
const rows = Math.ceil(n / cols)
|
||||
return {
|
||||
'grid-template-columns': `repeat(${cols}, minmax(0, 1fr))`,
|
||||
'grid-template-rows': `repeat(${rows}, minmax(0, 1fr))`
|
||||
}
|
||||
}
|
||||
const cols = Math.ceil(Math.sqrt(n))
|
||||
const rows = Math.ceil(n / cols)
|
||||
return {
|
||||
'grid-template-columns': `repeat(${cols}, minmax(0, 1fr))`,
|
||||
'grid-template-rows': `repeat(${rows}, minmax(0, 1fr))`
|
||||
}
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
'grid-template-columns': `repeat(${columns.value}, minmax(0, 1fr))`,
|
||||
'grid-template-rows': `repeat(${rows.value}, minmax(0, 1fr))`
|
||||
}))
|
||||
|
||||
const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||
/** 单元格特殊 class:3 人三角布局里,首个 tile 左列跨 2 行 */
|
||||
const cellClassFor = (idx) => {
|
||||
if (props.tiles.length === 3 && idx === 0) return 'cell-span-row-2'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -76,6 +123,12 @@ const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
animation: tile-slide-in 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* 3 人布局:首个 tile 左列占据整列高度(2 行) */
|
||||
.cell-span-row-2 {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -89,12 +142,32 @@ const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* 移动端单人时占满整屏 */
|
||||
@media (max-width: 640px) {
|
||||
.grid.layout-1 { grid-template-rows: 1fr !important; }
|
||||
.grid.layout-2 {
|
||||
/* Task 15 原创特色 5:入会滑入动效 */
|
||||
@keyframes tile-slide-in {
|
||||
from { transform: translateY(16rpx); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 移动端(小屏):柔性布局回退为纵向等分,避免 65/35 在窄屏上过度挤压 */
|
||||
@media (max-width: 750px) {
|
||||
.grid.layout-2-flex {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 1fr 1fr !important;
|
||||
}
|
||||
.grid.layout-3-tri {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 1fr 1fr 1fr !important;
|
||||
}
|
||||
.cell-span-row-2 {
|
||||
grid-row: span 1;
|
||||
}
|
||||
.grid.layout-1 {
|
||||
grid-template-rows: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 可访问性:系统偏好减少动效时禁用滑入 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-cell { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -165,10 +165,40 @@ onBeforeUnmount(() => {
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
/* Fallback:不支持 @property 的浏览器(Safari 17-)降级为静态蓝色描边 */
|
||||
.tile.speaking {
|
||||
border-color: #3B82F6;
|
||||
box-shadow: 0 0 0 2rpx rgba(59, 130, 246, 0.18), 0 0 28rpx rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
/* EchoChat 原创特色:说话者流光轮廓
|
||||
通过 @property 声明可动画化的自定义属性 --flow-angle,
|
||||
驱动 conic-gradient 环绕旋转;双层 background 方案:
|
||||
- padding-box:实色填充视频块内容
|
||||
- border-box:渐变仅生效于 border 区域,形成霓虹光圈效果 */
|
||||
@supports (background: conic-gradient(from 0deg, red, blue)) and (--flow-angle: 0deg) {
|
||||
@property --flow-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
.tile.speaking {
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(#0B1220, #0B1220) padding-box,
|
||||
conic-gradient(from var(--flow-angle), #10B981, #2563EB, #6366F1, #F59E0B, #10B981) border-box;
|
||||
box-shadow: 0 0 24rpx rgba(99, 102, 241, 0.45);
|
||||
animation: flow-rotate 3s linear infinite;
|
||||
}
|
||||
}
|
||||
@keyframes flow-rotate {
|
||||
to { --flow-angle: 360deg; }
|
||||
}
|
||||
|
||||
/* 可访问性:尊重系统减少动画偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile.speaking { animation: none; }
|
||||
}
|
||||
.tile.local::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -188,7 +188,13 @@ const stopAudioMeter = () => {
|
||||
const startPreview = async () => {
|
||||
// #ifdef H5
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||||
// 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
|
||||
}
|
||||
stopPreviewStream()
|
||||
@@ -339,15 +345,33 @@ const onJoin = async () => {
|
||||
startVideo: mediaPrefs.startVideo
|
||||
}
|
||||
|
||||
// 释放预览流,避免与 mediasoup produce 争用摄像头(浏览器单轨互斥)
|
||||
stopPreviewStream()
|
||||
// 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 && !mediaPrefs.startAudio) {
|
||||
try { rawAudioTrack.stop() } catch {}
|
||||
}
|
||||
if (rawVideoTrack && !mediaPrefs.startVideo) {
|
||||
try { rawVideoTrack.stop() } catch {}
|
||||
}
|
||||
const audioTrack = mediaPrefs.startAudio ? rawAudioTrack : null
|
||||
const videoTrack = mediaPrefs.startVideo ? rawVideoTrack : null
|
||||
// previewStream 对象本身不再需要持有,tracks 已取出交给 MediaEngine;
|
||||
// 避免在后续 onUnload 中误调 stopPreviewStream 把 track 给 stop 掉。
|
||||
previewStream = null
|
||||
|
||||
const prefs = {
|
||||
startAudio: mediaPrefs.startAudio,
|
||||
startVideo: mediaPrefs.startVideo,
|
||||
audioDeviceId: selectedAudioId.value,
|
||||
videoDeviceId: selectedVideoId.value
|
||||
videoDeviceId: selectedVideoId.value,
|
||||
audioTrack,
|
||||
videoTrack
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -367,6 +391,9 @@ const onJoin = async () => {
|
||||
const msg = err?.message || JSON.stringify(err)
|
||||
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 {
|
||||
joining.value = false
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||||
<view class="body">
|
||||
<view class="video-col">
|
||||
<VideoGrid :tiles="tiles">
|
||||
<view ref="videoCol" class="video-col">
|
||||
<VideoGrid :tiles="gridTiles">
|
||||
<template #tile="{ tile }">
|
||||
<VideoTile
|
||||
:user-id="tile.userId"
|
||||
@@ -48,9 +48,25 @@
|
||||
:video-enabled="tile.videoEnabled"
|
||||
:audio-track="tile.audioTrack"
|
||||
:video-track="tile.videoTrack"
|
||||
:is-speaking="!!speakingMap[tile.userId]"
|
||||
/>
|
||||
</template>
|
||||
</VideoGrid>
|
||||
|
||||
<!-- 自视频浮窗(Task 15):仅桌面端浮窗模式开启时显示;图钉切换后本地 tile 回到网格里 -->
|
||||
<SelfVideoFloat
|
||||
v-if="floatEnabled && selfTile"
|
||||
:user-id="selfTile.userId"
|
||||
:name="selfTile.name"
|
||||
:is-host="selfTile.isHost"
|
||||
:audio-enabled="selfTile.audioEnabled"
|
||||
:video-enabled="selfTile.videoEnabled"
|
||||
:audio-track="selfTile.audioTrack"
|
||||
:video-track="selfTile.videoTrack"
|
||||
:is-speaking="!!speakingMap[selfTile.userId]"
|
||||
:container="videoCol"
|
||||
@pin-click="togglePin"
|
||||
/>
|
||||
</view>
|
||||
<view class="chat-col" :class="{ open: chatVisible }">
|
||||
<ChatPanel
|
||||
@@ -77,6 +93,7 @@
|
||||
:member-count="tiles.length"
|
||||
:unread-chat-count="unreadChatCount"
|
||||
:allow-chat="allowChat"
|
||||
:all-muted="allMuted"
|
||||
@mic-toggle="onMicToggle"
|
||||
@cam-toggle="onCamToggle"
|
||||
@invite="openInvite"
|
||||
@@ -96,6 +113,7 @@
|
||||
@close="memberVisible = false"
|
||||
@kick="onKickMember"
|
||||
@transfer-host="onTransferHost"
|
||||
@mute-member="onMuteMember"
|
||||
/>
|
||||
|
||||
<!-- 邀请弹窗 -->
|
||||
@@ -149,6 +167,7 @@ import MemberPanel from '@/components/meeting/MemberPanel.vue'
|
||||
import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
||||
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
||||
import ChatPanel from '@/components/meeting/ChatPanel.vue'
|
||||
import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -160,11 +179,20 @@ const leaveDialogVisible = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const chatLoadingMore = ref(false)
|
||||
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
|
||||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||||
const startedAt = ref(0)
|
||||
const nowTs = ref(Date.now())
|
||||
let timerHandle = null
|
||||
|
||||
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
||||
const videoCol = ref(null)
|
||||
|
||||
/** 说话者映射由 meeting store 探测出(Task 15),浮窗和网格都订阅它来驱动流光动效 */
|
||||
const speakingMap = computed(() => meetingStore.speakingMap)
|
||||
|
||||
/** 全员静音标记,用于 Task 15 工具栏冷色氛围(允许单人时恒为 false) */
|
||||
const allMuted = computed(() => meetingStore.isAllMuted)
|
||||
|
||||
// ============ 基础映射 ============
|
||||
|
||||
const myUserId = computed(() => userStore.userInfo?.id || 0)
|
||||
@@ -307,23 +335,28 @@ const tiles = computed(() => {
|
||||
participants.value.forEach(p => {
|
||||
if (!p.is_active && p.is_active !== undefined) return
|
||||
const isLocal = p.user_id === myUid
|
||||
let audioTrack = null
|
||||
let videoTrack = null
|
||||
if (isLocal) {
|
||||
audioTrack = meetingStore.getLocalTrack('audio')
|
||||
videoTrack = meetingStore.getLocalTrack('video')
|
||||
} else {
|
||||
audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||
videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||
}
|
||||
// Task 15 回归修复:tile 是否渲染 <video>/<audio> 以 track 实际存在为准
|
||||
// 参会者的 audio_enabled/video_enabled 仅作为 MemberPanel 状态图标色彩依据,
|
||||
// 不再决定 VideoTile 媒体元素的挂载;否则一旦 state 字段滞后就会屏蔽实际画面
|
||||
const tile = {
|
||||
key: `u_${p.user_id}`,
|
||||
userId: p.user_id,
|
||||
name: isLocal ? '你' : nameOf(p.user_id),
|
||||
isLocal,
|
||||
isHost: p.user_id === hostId.value,
|
||||
audioEnabled: isLocal ? audioEnabled.value : (p.audio_enabled !== false),
|
||||
videoEnabled: isLocal ? videoEnabled.value : (p.video_enabled !== false),
|
||||
audioTrack: null,
|
||||
videoTrack: null
|
||||
}
|
||||
if (isLocal) {
|
||||
tile.audioTrack = meetingStore.getLocalTrack('audio')
|
||||
tile.videoTrack = meetingStore.getLocalTrack('video')
|
||||
} else {
|
||||
tile.audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||
tile.videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||
audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
|
||||
videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
|
||||
audioTrack,
|
||||
videoTrack
|
||||
}
|
||||
list.push(tile)
|
||||
})
|
||||
@@ -344,6 +377,36 @@ const tiles = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// ============ Task 15 浮窗模式 ============
|
||||
|
||||
/**
|
||||
* 浮窗开关:从 store.uiPrefs 读取,默认桌面端开启浮窗
|
||||
* 点击图钉按钮时 togglePin 切换,本地 tile 会在浮窗 <-> 网格间迁移
|
||||
*/
|
||||
const floatEnabled = computed(() => meetingStore.uiPrefs?.selfVideoFloat !== false)
|
||||
|
||||
/**
|
||||
* 自视频 tile:浮窗模式下从 tiles 中剥离出来单独挂载
|
||||
* 若 floatEnabled=false 则返回 null,自视频仍保留在 Grid 里
|
||||
*/
|
||||
const selfTile = computed(() => {
|
||||
if (!floatEnabled.value) return null
|
||||
return tiles.value.find(t => t.isLocal) || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 网格 tiles:浮窗开启时剔除本地 tile(只渲染远端),否则保持原始
|
||||
* 这样 VideoGrid 的 layout-1/2/3/N 档位判断基于远端人数,符合用户认知
|
||||
*/
|
||||
const gridTiles = computed(() => {
|
||||
if (!floatEnabled.value) return tiles.value
|
||||
return tiles.value.filter(t => !t.isLocal)
|
||||
})
|
||||
|
||||
const togglePin = () => {
|
||||
meetingStore.uiPrefs.selfVideoFloat = !meetingStore.uiPrefs.selfVideoFloat
|
||||
}
|
||||
|
||||
// ============ 事件处理 ============
|
||||
|
||||
const onMicToggle = async () => {
|
||||
@@ -439,6 +502,15 @@ const onKickMember = async (uid) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onMuteMember = async ({ userId: uid, mute }) => {
|
||||
try {
|
||||
await meetingStore.muteMember(uid, mute)
|
||||
uni.showToast({ title: mute ? '已请对方静音' : '已请对方开麦', icon: 'none' })
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onTransferHost = async (uid) => {
|
||||
const confirmed = await new Promise(resolve => {
|
||||
uni.showModal({
|
||||
|
||||
@@ -13,7 +13,21 @@
|
||||
import { BASE_URL } from '@/utils/request'
|
||||
import { getToken } from '@/utils/storage'
|
||||
|
||||
const WS_BASE = BASE_URL.replace(/^http/, 'ws')
|
||||
// WebSocket 基础地址推导:
|
||||
// - 若 BASE_URL 非空(生产 CDN 直连场景),将其协议从 http(s) 替换成 ws(s)
|
||||
// - 若 BASE_URL 为空(开发 vite proxy / 生产同源 Nginx),用当前页面 origin 推导同源 ws(s)
|
||||
// 这样 WebSocket 天然随页面协议走:HTTPS 页面用 wss,HTTP 页面用 ws,避免混合内容被浏览器阻断
|
||||
const resolveWsBase = () => {
|
||||
if (BASE_URL) return BASE_URL.replace(/^http/, 'ws')
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${wsProto}//${window.location.host}`
|
||||
}
|
||||
// #endif
|
||||
return ''
|
||||
}
|
||||
const WS_BASE = resolveWsBase()
|
||||
const HEARTBEAT_INTERVAL = 30000
|
||||
const RECONNECT_BASE_DELAY = 1000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
|
||||
@@ -143,6 +143,31 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
/** MediaEngine 实例(H5 才有值),用 markRaw 包裹避免 Vue 深度代理 */
|
||||
let _engine = null
|
||||
|
||||
/**
|
||||
* 说话者探测:{ [userId]: boolean },由 UI 订阅驱动 VideoTile.speaking 边框
|
||||
* Task 15 原创特色 1——EchoChat 会议专属流光轮廓
|
||||
*/
|
||||
const speakingMap = reactive({})
|
||||
|
||||
/**
|
||||
* UI 偏好(内存态,不持久化到 storage)
|
||||
* - selfVideoFloat: 自视频是否走浮窗模式(桌面端默认 true)
|
||||
*/
|
||||
const uiPrefs = reactive({
|
||||
selfVideoFloat: true
|
||||
})
|
||||
|
||||
/**
|
||||
* 说话者探测内部状态:handle + 本地 WebAudio 实例
|
||||
* 所有字段均为 H5 浏览器 API,非 H5 平台永远保持 null
|
||||
*/
|
||||
let _speakingTimer = null
|
||||
let _localAudioCtx = null
|
||||
let _localAnalyser = null
|
||||
let _localSource = null
|
||||
/** { [userId]: { inCount: number, outCount: number } },用于防抖 1-in / 2-out */
|
||||
const _speakingDebounce = {}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
const isInMeeting = computed(() => {
|
||||
@@ -162,6 +187,23 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
return participants.value.filter(p => p.is_active)
|
||||
})
|
||||
|
||||
/**
|
||||
* 全员静音标记:用于 Task 15 原创特色 4——静音氛围色
|
||||
* 规则:仅当参与者 ≥ 2 人且所有人 audio_enabled=false 时 true
|
||||
* 单人会议不触发(独自一人不算"全员静音氛围")
|
||||
*/
|
||||
const isAllMuted = computed(() => {
|
||||
const list = activeParticipants.value
|
||||
if (list.length < 2) return false
|
||||
const userStore = useUserStore()
|
||||
const myUid = userStore.userInfo?.id
|
||||
return list.every(p => {
|
||||
// 本地 audio 以 localAudioEnabled 为准(后端不会给自己广播 state.changed)
|
||||
if (myUid && p.user_id === myUid) return !localAudioEnabled.value
|
||||
return p.audio_enabled === false
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== 私有工具 ====================
|
||||
|
||||
const _log = (level, ...args) => {
|
||||
@@ -236,22 +278,63 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
lastEndedReason.value = data.reason || ''
|
||||
_log('info', '[Meeting] 会议已结束', data.reason, MEETING_ENDED_REASON_LABEL[data.reason])
|
||||
_cleanupMedia()
|
||||
// 清理 producer/consumer 索引与本地开关,防止下次 createAndEnter/joinAndEnter 时
|
||||
// startLocalAudio/Video 因 `if (localProducers.audio) return` 被残留 ID 拦下,
|
||||
// 表现为"重新发起会议后看不到自己的画面"
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
localAudioEnabled.value = false
|
||||
localVideoEnabled.value = false
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
localState.value = MEETING_LOCAL_STATE_ENDED
|
||||
_unregisterListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 REST getRoom 返回的 participants 与当前内存状态
|
||||
* - REST DTO 目前不包含 audio_enabled/video_enabled(后端 DTO 未暴露该字段)
|
||||
* - WS 广播驱动的内存状态(p.audio_enabled / p.video_enabled)可能已更新;REST 覆盖会回退
|
||||
* - 策略:以 REST 返回的静态属性为主,保留本地已知的音视频开关状态;首次未知时留 undefined
|
||||
* 交由 state.changed 广播权威填充,避免误将"未知"冲成 false 引起 UI 错判
|
||||
*/
|
||||
const _mergeParticipantsFromRest = (restList) => {
|
||||
const byUid = new Map()
|
||||
participants.value.forEach((p) => byUid.set(p.user_id, p))
|
||||
return restList.map((p) => {
|
||||
const prev = byUid.get(p.user_id)
|
||||
const merged = { ...p }
|
||||
if (typeof p.audio_enabled !== 'boolean' && prev && typeof prev.audio_enabled === 'boolean') {
|
||||
merged.audio_enabled = prev.audio_enabled
|
||||
}
|
||||
if (typeof p.video_enabled !== 'boolean' && prev && typeof prev.video_enabled === 'boolean') {
|
||||
merged.video_enabled = prev.video_enabled
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
const _onMemberJoined = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
// 后端广播目前只带基础字段,完整 DTO 通过 GET /rooms/:code 二次拉取
|
||||
if (!participants.value.find(p => p.user_id === data.user_id)) {
|
||||
participants.value.push({
|
||||
user_id: data.user_id,
|
||||
joined_at: data.joined_at,
|
||||
is_active: true,
|
||||
role: 0
|
||||
})
|
||||
// 后端广播已带 user_name/user_avatar,直接补齐昵称头像字段即可
|
||||
const existed = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (existed) {
|
||||
if (data.user_name) existed.user_name = data.user_name
|
||||
if (data.user_avatar) existed.user_avatar = data.user_avatar
|
||||
existed.is_active = true
|
||||
return
|
||||
}
|
||||
// Task 15:新入会者的 audio_enabled/video_enabled 保持 undefined,
|
||||
// 等 startLocalAudio/Video 成功后的 meeting.member.state.changed 广播权威填充
|
||||
// MemberPanel 对 undefined 按 !p.audio_enabled 解读为 off(灰),语义"尚未开启"与预期一致
|
||||
participants.value.push({
|
||||
user_id: data.user_id,
|
||||
user_name: data.user_name || '',
|
||||
user_avatar: data.user_avatar || '',
|
||||
joined_at: data.joined_at,
|
||||
is_active: true,
|
||||
role: 0
|
||||
})
|
||||
}
|
||||
|
||||
const _onMemberLeft = (msg) => {
|
||||
@@ -268,10 +351,39 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const _onMemberStateChanged = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
const p = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (p) {
|
||||
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
|
||||
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
|
||||
let p = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (!p) {
|
||||
// 时序兜底:OnRoomJoin 触发的 existing state.changed 可能早于 REST getRoom 返回
|
||||
// 此时 participants 里还没有 data.user_id 条目。建个占位,等后续 REST / member.joined
|
||||
// 在 _mergeParticipantsFromRest 里补齐 user_name/avatar 即可,本处先把状态保留住
|
||||
p = {
|
||||
user_id: data.user_id,
|
||||
user_name: '',
|
||||
user_avatar: '',
|
||||
is_active: true,
|
||||
role: 0
|
||||
}
|
||||
participants.value.push(p)
|
||||
}
|
||||
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
|
||||
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
|
||||
|
||||
// Task 15:主持人通过 meeting.member.state.changed 带 target_user_id 指令静音他人,
|
||||
// 后端广播字段名参见 meeting_signal_service.OnMemberStateChanged:
|
||||
// data.user_id = 被操作者(即当前收到广播的用户)
|
||||
// data.changed_by = 发起操作者(主持人)
|
||||
const userStore = useUserStore()
|
||||
const myUid = userStore.userInfo?.id
|
||||
const isTargetMe = myUid && data.user_id === myUid
|
||||
const isByOthers = data.changed_by && data.changed_by !== myUid
|
||||
if (isTargetMe && isByOthers && typeof data.audio_enabled === 'boolean' && !data.audio_enabled) {
|
||||
// 被主持人请求静音 → 立即关闭本地 audio producer
|
||||
if (localAudioEnabled.value) {
|
||||
stopLocalAudio().catch((err) => _log('warn', '[Meeting] 被动静音失败', err))
|
||||
}
|
||||
// #ifdef H5
|
||||
try { uni.showToast({ title: '主持人请你静音', icon: 'none', duration: 2000 }) } catch {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +518,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(roomCode)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = detail.participants
|
||||
participants.value = _mergeParticipantsFromRest(detail.participants)
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 重绑定后拉取房间详情失败', e)
|
||||
@@ -421,20 +533,30 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
// ==================== 远端媒体清理 ====================
|
||||
|
||||
/**
|
||||
* 精确关闭"某用户某个 producer 对应的 consumer"
|
||||
*
|
||||
* mediasoup-client 的 Consumer 对象自带 `producerId` 字段(transport.consume 时注入),
|
||||
* 利用它匹配 slot.audio / slot.video 中归属 producerId 的那一个;
|
||||
* 其他 kind 的 consumer 必须保留,否则会出现"关音频连带把视频画面也关掉"的连锁问题。
|
||||
*
|
||||
* 找不到匹配时(例如 producerId 异常),不再做 MVP 时期的"整槽关闭"兜底,
|
||||
* 而是只把 producerIds Set 条目移掉;真正该关的 consumer 由后续 transportclose / 离会清理接管
|
||||
*/
|
||||
const _cleanupRemoteProducer = (userId, producerId) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
slot.producerIds.delete(producerId)
|
||||
// 无法从 producerId 直接反查 consumer;简单策略:关 slot 内所有 consumer 然后重建(MVP)
|
||||
// 但更稳的:把 Consumer 索引按 producerId 存一遍
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
const consumer = slot[kind]
|
||||
if (consumer && !consumer.closed) {
|
||||
if (!consumer) return
|
||||
if (consumer.producerId && consumer.producerId !== producerId) return
|
||||
if (!consumer.closed) {
|
||||
try { consumer.close() } catch {}
|
||||
slot[kind] = null
|
||||
}
|
||||
slot[kind] = null
|
||||
})
|
||||
if (slot.producerIds.size === 0) {
|
||||
if (!slot.audio && !slot.video && slot.producerIds.size === 0) {
|
||||
delete remoteConsumers[userId]
|
||||
}
|
||||
}
|
||||
@@ -451,12 +573,174 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
|
||||
const _cleanupMedia = () => {
|
||||
_stopSpeakingDetection()
|
||||
if (_engine) {
|
||||
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
|
||||
_engine = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 说话者探测(Task 15 原创特色 1) ====================
|
||||
|
||||
/**
|
||||
* 音量阈值(0-1 线性,RMS / audioLevel 均归一化到此区间)
|
||||
* 0.05 为静默阈值,比正常说话(0.15-0.4)低 3-8 倍,可过滤键盘敲击背景噪声
|
||||
*/
|
||||
const _SPEAKING_LEVEL_THRESHOLD = 0.05
|
||||
/** 探测轮询间隔(ms),500ms 已足够识别说话停顿 */
|
||||
const _SPEAKING_TICK_MS = 500
|
||||
|
||||
/** 读取远端 Consumer 的即时音量(0-1) */
|
||||
const _readRemoteAudioLevel = (consumer) => {
|
||||
// #ifdef H5
|
||||
if (!consumer || !consumer.rtpReceiver) return 0
|
||||
try {
|
||||
const sources = consumer.rtpReceiver.getSynchronizationSources?.() || []
|
||||
if (sources.length === 0) return 0
|
||||
// audioLevel 是 0-1 的浮点数(W3C Spec),Chrome/Edge 原生支持
|
||||
// Firefox 可能返回 undefined,此时回退为 0(不影响其他远端检测)
|
||||
const level = sources[0].audioLevel
|
||||
return typeof level === 'number' ? level : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return 0
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 读取本地 AnalyserNode 的 RMS(0-1) */
|
||||
const _readLocalAudioLevel = () => {
|
||||
// #ifdef H5
|
||||
if (!_localAnalyser) return 0
|
||||
try {
|
||||
const buf = new Float32Array(_localAnalyser.fftSize)
|
||||
_localAnalyser.getFloatTimeDomainData(buf)
|
||||
let sum = 0
|
||||
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
|
||||
const rms = Math.sqrt(sum / buf.length)
|
||||
// RMS 天然偏小,乘 3 放大到与 audioLevel 相似数量级
|
||||
return Math.min(1, rms * 3)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return 0
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖更新:level >= 阈值 1 tick 即判定 speaking=true,
|
||||
* level < 阈值连续 2 tick 才 false,避免说话时短暂停顿 UI 闪烁
|
||||
*/
|
||||
const _updateSpeakingWithDebounce = (userId, level) => {
|
||||
if (!_speakingDebounce[userId]) {
|
||||
_speakingDebounce[userId] = { inCount: 0, outCount: 0 }
|
||||
}
|
||||
const state = _speakingDebounce[userId]
|
||||
const above = level >= _SPEAKING_LEVEL_THRESHOLD
|
||||
if (above) {
|
||||
state.inCount += 1
|
||||
state.outCount = 0
|
||||
if (state.inCount >= 1 && !speakingMap[userId]) {
|
||||
speakingMap[userId] = true
|
||||
}
|
||||
} else {
|
||||
state.inCount = 0
|
||||
state.outCount += 1
|
||||
if (state.outCount >= 2 && speakingMap[userId]) {
|
||||
speakingMap[userId] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化本地 WebAudio 管道(仅在有本地 audio track 时建) */
|
||||
const _ensureLocalAnalyser = () => {
|
||||
// #ifdef H5
|
||||
const track = getLocalTrack('audio')
|
||||
if (!track) {
|
||||
_teardownLocalAnalyser()
|
||||
return
|
||||
}
|
||||
// 若已有管道且 track 未变,直接复用;否则重建(例:麦克风切换设备)
|
||||
if (_localAnalyser && _localSource && _localSource.mediaStream?.getAudioTracks()[0]?.id === track.id) {
|
||||
return
|
||||
}
|
||||
_teardownLocalAnalyser()
|
||||
try {
|
||||
const AC = window.AudioContext || window.webkitAudioContext
|
||||
if (!AC) return
|
||||
_localAudioCtx = new AC()
|
||||
const stream = new MediaStream([track])
|
||||
_localSource = _localAudioCtx.createMediaStreamSource(stream)
|
||||
_localAnalyser = _localAudioCtx.createAnalyser()
|
||||
_localAnalyser.fftSize = 1024
|
||||
_localSource.connect(_localAnalyser)
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 本地 AnalyserNode 初始化失败', e)
|
||||
_teardownLocalAnalyser()
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
const _teardownLocalAnalyser = () => {
|
||||
// #ifdef H5
|
||||
try { _localSource?.disconnect?.() } catch {}
|
||||
try { _localAnalyser?.disconnect?.() } catch {}
|
||||
try { _localAudioCtx?.close?.() } catch {}
|
||||
_localSource = null
|
||||
_localAnalyser = null
|
||||
_localAudioCtx = null
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 500ms 轮询:计算所有远端 + 本地的音量并防抖更新 speakingMap */
|
||||
const _speakingTick = () => {
|
||||
const userStore = useUserStore()
|
||||
const myUid = userStore.userInfo?.id
|
||||
|
||||
// 远端:遍历所有有 audio consumer 的用户
|
||||
Object.keys(remoteConsumers).forEach(uidStr => {
|
||||
const uid = Number(uidStr)
|
||||
const slot = remoteConsumers[uid]
|
||||
const level = slot?.audio ? _readRemoteAudioLevel(slot.audio) : 0
|
||||
_updateSpeakingWithDebounce(uid, level)
|
||||
})
|
||||
|
||||
// 本地:仅在本地 audio 开启时探测
|
||||
if (myUid) {
|
||||
if (localAudioEnabled.value) {
|
||||
_ensureLocalAnalyser()
|
||||
const level = _readLocalAudioLevel()
|
||||
_updateSpeakingWithDebounce(myUid, level)
|
||||
} else {
|
||||
// 关麦后直接清零并销毁 AnalyserNode
|
||||
if (speakingMap[myUid]) speakingMap[myUid] = false
|
||||
if (_speakingDebounce[myUid]) _speakingDebounce[myUid] = { inCount: 0, outCount: 0 }
|
||||
_teardownLocalAnalyser()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _startSpeakingDetection = () => {
|
||||
if (_speakingTimer) return
|
||||
// #ifdef H5
|
||||
_speakingTimer = setInterval(_speakingTick, _SPEAKING_TICK_MS)
|
||||
// #endif
|
||||
}
|
||||
|
||||
const _stopSpeakingDetection = () => {
|
||||
if (_speakingTimer) {
|
||||
clearInterval(_speakingTimer)
|
||||
_speakingTimer = null
|
||||
}
|
||||
_teardownLocalAnalyser()
|
||||
Object.keys(speakingMap).forEach(k => delete speakingMap[k])
|
||||
Object.keys(_speakingDebounce).forEach(k => delete _speakingDebounce[k])
|
||||
}
|
||||
|
||||
// ==================== 进入会议(公共私有) ====================
|
||||
|
||||
/**
|
||||
@@ -487,7 +771,19 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
_registerListeners()
|
||||
|
||||
// 提前拉取参与者列表:让 OnRoomJoin 触发的 existing state.changed / producer.new
|
||||
// 到达时 participants 已就绪,避免后入者的 _onMemberStateChanged 找不到 participant 而丢状态
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = _mergeParticipantsFromRest(detail.participants)
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
|
||||
}
|
||||
|
||||
// 告知后端 WS 我已绑定本房间(用于 WS 连接 ↔ roomCode 映射)
|
||||
// 后端在此事件内部异步 pushExistingRoomState,补推房间其他用户的 producer.new 与 state.changed
|
||||
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, {
|
||||
room_code: currentRoom.value.room_code
|
||||
})
|
||||
@@ -496,27 +792,33 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await _engine.ensureSendTransport()
|
||||
await _engine.ensureRecvTransport()
|
||||
|
||||
// 拉取最新参与者列表(广播里只有 ID,这里补齐完整 DTO)
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = detail.participants
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
|
||||
}
|
||||
|
||||
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
||||
|
||||
// 启动说话者探测(Task 15):500ms 轮询,远端读 RTP audioLevel,本地用 WebAudio RMS
|
||||
_startSpeakingDetection()
|
||||
|
||||
// mediaPrefs 自动推流:失败仅告警,不阻断入会流程,允许用户在会议室内手动重试开麦/开摄像头
|
||||
// 预览页已申请过 track 时会通过 mediaPrefs.audioTrack/videoTrack 透传过来,避免 iOS 二次 getUserMedia
|
||||
if (mediaPrefs) {
|
||||
if (mediaPrefs.startAudio) {
|
||||
try { await startLocalAudio(mediaPrefs.audioDeviceId) }
|
||||
catch (e) { _log('warn', '[Meeting] 自动开麦失败', e) }
|
||||
try {
|
||||
await startLocalAudio(mediaPrefs.audioDeviceId, mediaPrefs.audioTrack)
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 自动开麦失败', e)
|
||||
// #ifdef H5
|
||||
try { uni.showToast({ title: `自动开麦失败:${e?.message || e?.name || e}`, icon: 'none', duration: 3500 }) } catch {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
if (mediaPrefs.startVideo) {
|
||||
try { await startLocalVideo(mediaPrefs.videoDeviceId) }
|
||||
catch (e) { _log('warn', '[Meeting] 自动开摄像头失败', e) }
|
||||
try {
|
||||
await startLocalVideo(mediaPrefs.videoDeviceId, mediaPrefs.videoTrack)
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 自动开摄像头失败', e)
|
||||
// #ifdef H5
|
||||
try { uni.showToast({ title: `自动开摄像头失败:${e?.message || e?.name || e}`, icon: 'none', duration: 3500 }) } catch {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +834,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
// 上一次会议若以 ENDED 收尾(被主持人/后端结束),Store 中仍会残留 participants/remoteConsumers/
|
||||
// devicePreview 等旧状态;显式 _reset 一次避免污染新会议(典型症状:再次入会看不到自己)
|
||||
_reset()
|
||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||
try {
|
||||
// 首次调用时抑制失败 toast:失败后若是 stale 残留会自动清理 + 重试,对用户无感
|
||||
@@ -572,6 +877,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
// 与 createAndEnter 同理:消除上一次 ENDED 会议残留的 Store 状态
|
||||
_reset()
|
||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||
try {
|
||||
// 首次调用时抑制失败 toast:失败后若是 stale 残留会自动清理 + 重试,对用户无感
|
||||
@@ -630,36 +937,47 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
* 开启本地音频
|
||||
* @param {string} [deviceId] - 指定麦克风 deviceId(从 enumerateDevices 获取),为空则走系统默认
|
||||
*/
|
||||
const startLocalAudio = async (deviceId) => {
|
||||
const startLocalAudio = async (deviceId, existingTrack) => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.audio) return
|
||||
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||
const track = stream.getAudioTracks()[0]
|
||||
// 优先复用预览页已申请的 track,避开 iOS Safari/Chrome 连续两次 getUserMedia
|
||||
// 触发 NotReadableError(预览页 track.stop() 后相机硬件有释放延迟)
|
||||
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
|
||||
if (!track) {
|
||||
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||
track = stream.getAudioTracks()[0]
|
||||
}
|
||||
const producer = await _engine.produce({ kind: 'audio', track })
|
||||
localProducers.audio = producer.id
|
||||
localAudioEnabled.value = true
|
||||
_broadcastSelfState({ audio_enabled: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启本地视频
|
||||
* @param {string} [deviceId] - 指定摄像头 deviceId,为空则走系统默认
|
||||
*/
|
||||
const startLocalVideo = async (deviceId) => {
|
||||
const startLocalVideo = async (deviceId, existingTrack) => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.video) return
|
||||
// 与预览页保持一致的 HD 分辨率(ideal 1280x720,允许浏览器在设备不支持时降级)
|
||||
const videoConstraints = {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 24, max: 30 },
|
||||
...(deviceId ? { deviceId: { exact: deviceId } } : {})
|
||||
// 优先复用预览页已申请的 track,避开 iOS Safari/Chrome 连续两次 getUserMedia
|
||||
// 触发 NotReadableError(预览页 track.stop() 后相机硬件有释放延迟)
|
||||
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
|
||||
if (!track) {
|
||||
const videoConstraints = {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 24, max: 30 },
|
||||
...(deviceId ? { deviceId: { exact: deviceId } } : {})
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })
|
||||
track = stream.getVideoTracks()[0]
|
||||
}
|
||||
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
|
||||
localVideoEnabled.value = true
|
||||
_broadcastSelfState({ video_enabled: true })
|
||||
}
|
||||
|
||||
/** 关闭本地音频 */
|
||||
@@ -668,6 +986,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await _engine.closeProducer(localProducers.audio)
|
||||
localProducers.audio = null
|
||||
localAudioEnabled.value = false
|
||||
_broadcastSelfState({ audio_enabled: false })
|
||||
}
|
||||
|
||||
/** 关闭本地视频 */
|
||||
@@ -676,6 +995,26 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await _engine.closeProducer(localProducers.video)
|
||||
localProducers.video = null
|
||||
localVideoEnabled.value = false
|
||||
_broadcastSelfState({ video_enabled: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 15:向房间其他成员广播自身音视频状态
|
||||
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
|
||||
* - 后端会广播给房间其他成员(不回显给本人)
|
||||
* - 容错:WS 未连接或 ACK 失败仅 warn 日志,不影响本地状态机
|
||||
*/
|
||||
const _broadcastSelfState = (patch) => {
|
||||
if (!currentRoom.value) return
|
||||
if (!patch || typeof patch !== 'object') return
|
||||
const payload = { room_code: currentRoom.value.room_code }
|
||||
if (typeof patch.audio_enabled === 'boolean') payload.audio_enabled = patch.audio_enabled
|
||||
if (typeof patch.video_enabled === 'boolean') payload.video_enabled = patch.video_enabled
|
||||
if (Object.keys(payload).length <= 1) return
|
||||
wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, payload, 3000)
|
||||
.catch((err) => {
|
||||
_log('warn', '[Meeting] 上报本地音视频状态失败', err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,6 +1091,25 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await meetingApi.kickMember(currentRoom.value.room_code, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主持人静音他人 / 请他开麦(Task 15 主持人权限四件套第 4 件)
|
||||
*
|
||||
* 通过 WS meeting.member.state.changed 携带 target_user_id,
|
||||
* 后端已实现 host 校验与广播逻辑(meeting_signal_service.go),前端仅需发送。
|
||||
*
|
||||
* @param {number} targetUserId 被操作对象 user_id
|
||||
* @param {boolean} mute true=静音,false=请他开麦
|
||||
*/
|
||||
const muteMember = async (targetUserId, mute = true) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
if (!isHost.value) throw new Error('仅主持人可操作')
|
||||
await wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, {
|
||||
room_code: currentRoom.value.room_code,
|
||||
target_user_id: targetUserId,
|
||||
audio_enabled: !mute
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const inviteUsers = async (inviteeIds) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
return meetingApi.inviteUsers(currentRoom.value.room_code, inviteeIds)
|
||||
@@ -809,12 +1167,15 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
remoteConsumers,
|
||||
draftCreatePayload,
|
||||
devicePreview,
|
||||
speakingMap,
|
||||
uiPrefs,
|
||||
|
||||
// getters
|
||||
isInMeeting,
|
||||
isConnected,
|
||||
isHost,
|
||||
activeParticipants,
|
||||
isAllMuted,
|
||||
|
||||
// lifecycle
|
||||
createAndEnter,
|
||||
@@ -837,6 +1198,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
markChatsRead,
|
||||
transferHost,
|
||||
kickMember,
|
||||
muteMember,
|
||||
inviteUsers,
|
||||
cleanupStaleMeetings,
|
||||
|
||||
|
||||
@@ -184,12 +184,32 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
||||
})
|
||||
}
|
||||
|
||||
/** 按需创建 sendTransport(已存在则复用) */
|
||||
/** in-flight Promise:sendTransport 创建并发锁;避免突发并发下重复创建 */
|
||||
let sendTransportPromise = null
|
||||
/** in-flight Promise:recvTransport 创建并发锁;Task 15 后入者 burst 订阅时尤其关键 */
|
||||
let recvTransportPromise = null
|
||||
|
||||
/** 按需创建 sendTransport(已存在则复用,并发调用共享同一个 in-flight Promise) */
|
||||
const ensureSendTransport = async () => {
|
||||
ensureDeviceLoaded()
|
||||
if (sendTransport && !sendTransport.closed) {
|
||||
return sendTransport
|
||||
}
|
||||
if (sendTransportPromise) {
|
||||
return sendTransportPromise
|
||||
}
|
||||
sendTransportPromise = (async () => {
|
||||
try {
|
||||
return await _createSendTransport()
|
||||
} finally {
|
||||
sendTransportPromise = null
|
||||
}
|
||||
})()
|
||||
return sendTransportPromise
|
||||
}
|
||||
|
||||
/** 首次创建 sendTransport 的底层流程,原本 inline 在 ensureSendTransport 中 */
|
||||
const _createSendTransport = async () => {
|
||||
const info = await requestTransportInfo('send')
|
||||
sendTransport = device.createSendTransport({
|
||||
id: info.id,
|
||||
@@ -203,12 +223,27 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
||||
return sendTransport
|
||||
}
|
||||
|
||||
/** 按需创建 recvTransport(已存在则复用) */
|
||||
/** 按需创建 recvTransport(已存在则复用,并发调用共享同一个 in-flight Promise) */
|
||||
const ensureRecvTransport = async () => {
|
||||
ensureDeviceLoaded()
|
||||
if (recvTransport && !recvTransport.closed) {
|
||||
return recvTransport
|
||||
}
|
||||
if (recvTransportPromise) {
|
||||
return recvTransportPromise
|
||||
}
|
||||
recvTransportPromise = (async () => {
|
||||
try {
|
||||
return await _createRecvTransport()
|
||||
} finally {
|
||||
recvTransportPromise = null
|
||||
}
|
||||
})()
|
||||
return recvTransportPromise
|
||||
}
|
||||
|
||||
/** 首次创建 recvTransport 的底层流程 */
|
||||
const _createRecvTransport = async () => {
|
||||
const info = await requestTransportInfo('recv')
|
||||
recvTransport = device.createRecvTransport({
|
||||
id: info.id,
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
|
||||
import { getToken, removeToken } from './storage'
|
||||
|
||||
// 基础 URL 配置,根据环境自动切换
|
||||
// 开发环境使用 window.location.hostname 动态获取主机名,支持局域网 IP 访问
|
||||
const BASE_URL = process.env.NODE_ENV === 'development'
|
||||
? `http://${window.location.hostname}:8085`
|
||||
: '' // 生产环境由 Nginx 反向代理,使用相对路径
|
||||
// 基础 URL 配置
|
||||
// 开发 / 生产环境都用相对路径(空串):
|
||||
// - 开发:vite 配置 server.proxy 把 /api /ws 转发到后端,同时开启 HTTPS;前端同源调用 /api/** 即可,
|
||||
// 同源意味着浏览器在 HTTPS 页面下不会因混合内容拒绝请求,且 WebRTC 所需的 secure context 自然满足。
|
||||
// - 生产:由 Nginx 反向代理到 Go 后端,同样同源。
|
||||
// 如果需要直连其他主机(极少数场景),可通过 VITE_API_BASE 在构建期覆盖。
|
||||
// eslint-disable-next-line no-undef
|
||||
const BASE_URL = (typeof __VITE_API_BASE__ !== 'undefined' && __VITE_API_BASE__) || ''
|
||||
|
||||
// 请求超时时间(毫秒)
|
||||
const TIMEOUT = 15000
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
//
|
||||
// HTTPS 说明(WebRTC 必需):
|
||||
// 浏览器仅在 secure context 下开放 navigator.mediaDevices(getUserMedia 等 API),
|
||||
// 手机通过局域网 IP http://192.168.x.x:5173 访问时 mediaDevices 会是 undefined。
|
||||
// 因此 dev 默认开启自签 HTTPS,同时把 /api 与 /ws 走同源代理转回后端 HTTP,
|
||||
// 这样浏览器只与 vite HTTPS 通信,前端代码无需关心后端是否 TLS。
|
||||
//
|
||||
// 代理目标由环境变量 VITE_DEV_BACKEND 控制,默认 http://localhost:8085,
|
||||
// 若后端跑在其他主机/端口(如 docker 主机)可自行覆盖。
|
||||
const BACKEND = process.env.VITE_DEV_BACKEND || 'http://localhost:8085'
|
||||
const BACKEND_WS = BACKEND.replace(/^http/, 'ws')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
uni(),
|
||||
basicSsl(),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: BACKEND,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: BACKEND_WS,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user