feat(im): 通话窗扬声器开关 + 按钮关闭态统一深色样式 + 群通话支持刷新后重新加入

- useLiveKitRoom 增加 speakerEnabled 状态 + setSpeakerEnabled;audio 元素 :muted 联动,实现扬声器实际开关
- mic / speaker / camera / 屏幕共享 4 个按钮关闭态统一 bg-white/15 深色(之前一直 bg-white 像「开」)
- speaker / camera / 屏幕共享 关闭态 icon 借用 tabler:volume-off / video-off / device-laptop-off 显斜线(ant-design 缺 muted 变体)
- RtcGroupCallBanner 修复刷新后无法重新加入:按钮文案改为「已在通话中 / 重新加入 / 加入」三态;按钮文字色锁定深色防暗色主题不可见
- RtcCallIncoming 对齐微信样式:右上角小条 + 横排(头像 / 名 / 按钮);群聊带「通话成员」头像行
- RtcCallRunning UnoCSS 重写 + 接收 isGroup prop(去 conversationType 派生)
- RtcCallParticipantTile UnoCSS 重写 + speakerEnabled 透传静音
- 注释 / UI 文案半角省略号 → 全角……;watcher 参数 hidden → suppressTick
im
YunaiV 2026-05-17 17:37:37 +08:00
parent 03d0ce800d
commit 5d222bdf48
5 changed files with 94 additions and 54 deletions

View File

@ -18,32 +18,33 @@
class="self-start"
/>
<!-- 群聊单行邀请人 + 文案+ 通话成员私聊两行 -->
<!-- 群聊单行邀请人 + 文案+ 通话成员私聊两行 / 文案 -->
<div class="flex flex-col flex-1 gap-1 self-start min-w-0">
<template v-if="isGroup">
<div class="text-sm truncate">
<span class="font-medium">{{ payload?.inviterNickname || '对方' }}</span>
<span class="ml-1 text-white/60">{{ tipText }}</span>
</div>
<template v-if="callMembers.length > 0">
<div class="mt-1 text-xs text-white/45">通话成员</div>
<div class="flex flex-wrap gap-1">
<UserAvatar
v-for="m in callMembers"
:key="m.userId"
:url="m.avatar"
:name="m.nickname"
:size="22"
radius="4px"
:clickable="false"
/>
</div>
</template>
</template>
<!-- + 文案群单行内联私聊上下两行 -->
<div v-if="isGroup" class="text-sm truncate">
<span class="font-medium">{{ payload?.inviterNickname || '对方' }}</span>
<span class="ml-1 text-white/60">{{ tipText }}</span>
</div>
<template v-else>
<div class="text-sm font-medium truncate">{{ payload?.inviterNickname || '对方' }}</div>
<div class="text-13px text-white/60 truncate">{{ tipText }}</div>
</template>
<!-- 群通话成员行私聊无 -->
<template v-if="isGroup && callMembers.length > 0">
<div class="mt-1 text-xs text-white/45">通话成员</div>
<div class="flex flex-wrap gap-1">
<UserAvatar
v-for="member in callMembers"
:key="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="22"
radius="4px"
:clickable="false"
/>
</div>
</template>
</div>
<!-- 右下角拒绝 / 接听 -->

View File

@ -1,7 +1,7 @@
<template>
<!-- 主叫等待对方接听的悬浮窗1v1 私聊 320×540群通话切大窗 720×560 -->
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[9999] flex flex-col text-white bg-gradient-to-b from-[#2a2a2c] to-[#1a1a1c]"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-gradient-to-b from-[#2a2a2c] to-[#1a1a1c]"
:class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'"
>
<div class="flex relative flex-1 justify-center items-center">
@ -26,7 +26,7 @@
:clickable="false"
/>
<div class="text-[17px] font-medium">{{ peerNickname || '对方' }}</div>
<div class="text-13px text-white/60">等待对方接受邀请...</div>
<div class="text-13px text-white/60">等待对方接受邀请</div>
</div>
</div>
@ -36,8 +36,10 @@
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-mic')"
>
<!-- ant-design 系列里 mic audio-muted-outlined 变体speaker / camera 没有 muted 变体off 态借 tabler:*-off 表达斜线 -->
<span
class="flex justify-center items-center w-12 h-12 bg-white rounded-full text-[#1a1a1c]"
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'"
@ -65,14 +67,11 @@
@click="$emit('toggle-camera')"
>
<span
class="flex justify-center items-center w-12 h-12 bg-white rounded-full text-[#1a1a1c]"
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="
cameraEnabled
? 'ant-design:video-camera-outlined'
: 'ant-design:video-camera-add-outlined'
"
:icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'"
:size="22"
/>
</span>
@ -86,11 +85,17 @@
@click="$emit('toggle-speaker')"
>
<span
class="flex justify-center items-center w-12 h-12 bg-white rounded-full text-[#1a1a1c]"
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon icon="ant-design:sound-outlined" :size="22" />
<Icon
:icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ speakerEnabled ? '扬声器已开' : '扬声器已关' }}
</span>
<span class="text-xs text-white/70 whitespace-nowrap">扬声器已开</span>
</div>
</div>
</div>
@ -108,6 +113,7 @@ const props = defineProps<{
isVideo: boolean
micEnabled: boolean
cameraEnabled: boolean
speakerEnabled: boolean
localStream?: MediaStream | null //
}>()

View File

@ -10,7 +10,7 @@
class="inline-flex absolute top-3 left-1/2 z-10 gap-2 items-center px-3.5 py-1.5 text-13px text-[#ffd45e] rounded-full -translate-x-1/2 bg-[rgba(255,196,0,0.18)]"
>
<span class="reconnect-dot w-2 h-2 rounded-full bg-[#ffd45e]"></span>
网络不佳正在重连...
网络不佳正在重连
</div>
<div class="flex relative flex-1 justify-center items-center">
<!-- 群通话网格布局列数随人数自适应 -->
@ -20,9 +20,9 @@
:class="gridColsClass"
>
<RtcCallParticipantTile
v-for="p in participants"
:key="p.userId"
:participant="p"
v-for="participant in participants"
:key="participant.userId"
:participant="participant"
:speaker-enabled="speakerEnabled"
/>
</div>
@ -46,7 +46,7 @@
:clickable="false"
/>
<div class="text-[17px] font-medium">{{ peerNickname }}</div>
<div class="text-13px text-white/60">等待对方开启摄像头...</div>
<div class="text-13px text-white/60">等待对方开启摄像头</div>
</div>
<div
v-if="localStream"
@ -146,7 +146,10 @@
class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
:class="screenShareEnabled ? 'bg-[#07c160] text-white' : 'bg-white/15 text-white'"
>
<Icon icon="ant-design:laptop-outlined" :size="22" />
<Icon
:icon="screenShareEnabled ? 'ant-design:laptop-outlined' : 'tabler:device-laptop-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ screenShareEnabled ? '停止共享' : '共享屏幕' }}
@ -235,8 +238,8 @@ const now = ref(Date.now())
let tick = 0
watch(
() => props.isGroup || props.isVideo,
(hidden) => {
if (hidden) {
(suppressTick) => {
if (suppressTick) {
if (tick) {
clearInterval(tick)
tick = 0

View File

@ -29,10 +29,15 @@
<!-- 展开面板在通话成员头像横排 + 加入按钮 -->
<div class="flex flex-col gap-4 items-center pt-2 pb-1">
<div class="flex flex-wrap gap-1.5 justify-center max-w-[240px]">
<div v-for="m in joinedMembers" :key="m.userId" class="inline-flex" :title="m.nickname">
<div
v-for="member in joinedMembers"
:key="member.userId"
class="inline-flex"
:title="member.nickname"
>
<UserAvatar
:url="m.avatar"
:name="m.nickname"
:url="member.avatar"
:name="member.nickname"
:size="40"
radius="6px"
:clickable="false"
@ -43,13 +48,13 @@
暂无成员在通话
</div>
</div>
<!-- 自己已在通话内时置灰显示已在通话中避免重复 join -->
<!-- 本端在通话内时置灰已在通话中服务端残留我但本端连接断了显示重新加入刷新页面后场景 -->
<button
class="w-[200px] h-9 text-sm font-medium rounded-lg cursor-pointer border-none bg-[#f1f1f3] text-[var(--el-text-color-primary)] transition-colors duration-150 disabled:cursor-not-allowed disabled:text-[var(--el-text-color-secondary)] hover:[&:not(:disabled)]:bg-[#e7e7ea]"
class="w-[200px] h-9 text-sm font-medium rounded-lg cursor-pointer border-none bg-[#f1f1f3] text-[#1a1a1c] transition-colors duration-150 disabled:cursor-not-allowed disabled:text-[#999] hover:[&:not(:disabled)]:bg-[#e7e7ea]"
:disabled="joinDisabled"
@click="handleJoin"
>
{{ joinDisabled ? '已在通话中' : '加入' }}
{{ joinLabel }}
</button>
</div>
</el-popover>
@ -136,15 +141,30 @@ const joinedMembers = computed(() => {
const joinedCount = computed(() => joinedMembers.value.length)
/** 加入按钮禁用:自己已经在该房间内(含本端正在 INVITING / RUNNING */
const joinDisabled = computed(() => {
/** 本端是否正在该房间通话(处于 INVITING / RUNNING */
const isInThisCall = computed(
() => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room
)
/**
* 服务端是否记录我已加入刷新后 LiveKit 连接已断但 webhook 还没把 status 标为 LEFT 时仍为 true
* 用于把按钮文案切到重新加入但不 disable 按钮
*/
const serverSaysJoined = computed(() => {
const myId = getCurrentUserId()
if (rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room) {
return true
}
return activeCall.value?.joinedUserIds?.includes(myId) ?? false
})
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
const joinDisabled = computed(() => isInThisCall.value)
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
const joinLabel = computed(() => {
if (isInThisCall.value) return '已在通话中'
if (serverSaysJoined.value) return '重新加入'
return '加入'
})
/** 主动加入:调 invite 命中已有 call 拿 tokenrtcStore 按 status 自动进 RUNNING */
async function handleJoin() {
const call = activeCall.value

View File

@ -30,6 +30,8 @@ export function useLiveKitRoom() {
const micEnabled = ref(true)
/** 摄像头开关 */
const cameraEnabled = ref(false)
/** 扬声器开关;浏览器无系统级 API通过 audio 元素 muted 属性实现远端音频静音 */
const speakerEnabled = ref(true)
/** 屏幕共享开关 */
const screenShareEnabled = ref(false)
/** 当前是否处于「重连中」;瞬断时 UI 显示提示而不强制结束通话 */
@ -99,7 +101,7 @@ export function useLiveKitRoom() {
.on(RoomEvent.ConnectionQualityChanged, (quality) => {
connectionQuality.value = quality
})
// 瞬断 → 显示「重连中」;不关通话窗,由 ICE restart 机制恢复
// 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复
.on(RoomEvent.Reconnecting, () => {
reconnecting.value = true
})
@ -113,7 +115,7 @@ export function useLiveKitRoom() {
disconnectedHandlers.forEach((cb) => cb())
})
// 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟;
// 预热 getUserMedia 与 WebSocket 握手并行,省 100300ms 串行延迟;
// 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨
const warmup = prewarmMedia(opts)
// 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送
@ -176,6 +178,11 @@ export function useLiveKitRoom() {
cameraEnabled.value = enabled
}
/** 切扬声器;仅切响应式状态,实际静音由模板上 audio 元素 :muted 绑定生效 */
function setSpeakerEnabled(enabled: boolean) {
speakerEnabled.value = enabled
}
/**
*
*
@ -264,6 +271,7 @@ export function useLiveKitRoom() {
reconnecting.value = false
micEnabled.value = true
cameraEnabled.value = false
speakerEnabled.value = true
screenShareEnabled.value = false
}
@ -275,12 +283,14 @@ export function useLiveKitRoom() {
connectionQuality,
micEnabled,
cameraEnabled,
speakerEnabled,
screenShareEnabled,
reconnecting,
connect,
disconnect,
setMicEnabled,
setCameraEnabled,
setSpeakerEnabled,
setScreenShareEnabled,
pickStream,
onDisconnected,