feat(im): 初始化消息转发 v0.3:第三次评审(语音播放全局互斥)

新点的语音停掉旧的,对齐微信 PC 语义;一次只播一条,主面板 / 历史抽屉 / 合并详情共享同一播放态,避免多窗口同时出声。

- 新增 useVoicePlayer composable:模块级单例 currentTask + play / stop;ended / error / play().catch 全部 once: true 收尾,避免 listener 泄漏;play(key, url) / stop(key?) / isPlaying(key) 以 Symbol 当播放身份
- MessageBubble setup 里 Symbol('im-message-bubble-voice') 生成实例级 voiceKey;voicePlaying 改成派生 computed,移除本地 currentAudio
- MessageBubble 卸载兜底:调 voicePlayer.stop(voiceKey) 仅停自己;防止删除消息 / 历史抽屉切筛选导致气泡消失但 audio 仍在响;不会误伤别处仍可见的同 url 气泡
- MessagePanel 切会话 watch 追加 voicePlayer.stop()
- MessageHistory 关闭抽屉 watch 追加 voicePlayer.stop()
- MessageMergeDetailDialog handleClose 追加 voicePlayer.stop()
im
YunaiV 2026-05-07 21:26:12 +08:00
parent 82d065c270
commit 0323566878
5 changed files with 113 additions and 29 deletions

View File

@ -0,0 +1,81 @@
import { computed, ref } from 'vue'
/**
*
*
*
*
* - MessageBubble toggle
* - MessagePanel stop()
* - MessageHistory / MessageMergeDetailDialog stop()
*
* key MessageBubble setup Symbol() / /
* url key
*/
export type VoiceKey = symbol
interface VoiceTask {
key: VoiceKey
url: string
audio: HTMLAudioElement
}
const currentTask = ref<VoiceTask | null>(null)
/**
*
*
* - key /
* - key task key
*/
function stop(key?: VoiceKey) {
const task = currentTask.value
if (!task) {
return
}
if (key !== undefined && task.key !== key) {
return
}
task.audio.pause()
task.audio.src = ''
currentTask.value = null
}
/**
*
*
* - key
* - key
*/
function play(key: VoiceKey, url: string) {
if (!url) {
return
}
if (currentTask.value?.key === key) {
stop()
return
}
stop()
const audio = new Audio(url)
const task: VoiceTask = { key, url, audio }
/** 播放结束 / 异常清栈;只清当前任务,避免被后续新任务的回调误清 */
const finalize = () => {
if (currentTask.value === task) {
currentTask.value = null
}
}
audio.addEventListener('ended', finalize, { once: true })
audio.addEventListener('error', finalize, { once: true })
currentTask.value = task
audio.play().catch(finalize)
}
export function useVoicePlayer() {
/** 当前播放的 key给气泡 / 调试用 */
const currentKey = computed(() => currentTask.value?.key ?? null)
/** 指定 key 是否正在播放 */
function isPlaying(key: VoiceKey): boolean {
return currentTask.value?.key === key
}
return { currentKey, isPlaying, play, stop }
}

View File

@ -159,7 +159,7 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime'
@ -179,6 +179,7 @@ import {
} from '@/views/im/utils/message'
import { summarizeMessageContent } from '@/views/im/utils/conversation'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
defineOptions({ name: 'ImMessageBubble' })
@ -298,36 +299,27 @@ function handleFileClick() {
window.open(filePayload.value.url, '_blank')
}
/** 语音点击 → 简单 Audio() toggle 播放 */
const voicePlaying = ref(false)
let currentAudio: HTMLAudioElement | null = null
/** 语音点击:托管给 useVoicePlayer 全局互斥播放,新点的语音会停掉旧的 */
const voicePlayer = useVoicePlayer()
/**
* 实例级唯一播放 key每个 MessageBubble 实例独立一份
*
* 不用 url key 是为了避免主面板 / 历史抽屉 / 合并详情同一条语音共享身份那样三处气泡会
* 同时显示播放态且任何一处卸载都会 stop 掉别处仍可见的播放
*/
const voiceKey = Symbol('im-message-bubble-voice')
const voicePlaying = computed(() => voicePlayer.isPlaying(voiceKey))
function handleVoiceClick() {
if (!voicePayload.value?.url) {
const url = voicePayload.value?.url
if (!url) {
return
}
if (voicePlaying.value && currentAudio) {
currentAudio.pause()
voicePlaying.value = false
return
}
currentAudio = new Audio(voicePayload.value.url)
voicePlaying.value = true
currentAudio.addEventListener('ended', () => {
voicePlaying.value = false
currentAudio = null
})
currentAudio.play().catch(() => {
voicePlaying.value = false
})
voicePlayer.play(voiceKey, url)
}
/** 气泡卸载兜底:传 key 让 stop 自己判别「是不是我」,不会误伤别人的播放 */
onBeforeUnmount(() => {
if (currentAudio) {
currentAudio.pause()
currentAudio.src = ''
currentAudio = null
}
voicePlaying.value = false
voicePlayer.stop(voiceKey)
})
</script>

View File

@ -261,6 +261,7 @@ import {
} from '@/views/im/utils/user'
import { buildFacePreviewText, buildRecallTip } from '@/views/im/utils/conversation'
import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
import {
ImConversationType,
ImMessageType,
@ -297,6 +298,7 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
const voicePlayer = useVoicePlayer()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const visible = computed({
@ -573,13 +575,14 @@ function onDialogOpen() {
datePickerValue.value = new Date()
}
/** v-model 关闭时复位(兼容父组件 props 直接置 false 的路径dialog @open 不一定再触发) */
/** v-model 关闭时复位 + 停语音(兼容父组件 props 直接置 false 的路径dialog @open 不一定再触发) */
watch(
() => props.modelValue,
(value) => {
if (!value) {
activeFilter.value = null
keyword.value = ''
voicePlayer.stop()
}
}
)

View File

@ -196,6 +196,7 @@ import MessageForwardDialog from './forward/MessageForwardDialog.vue'
import MessageMergeDetailDialog from './forward/MessageMergeDetailDialog.vue'
import { IM_FORWARD_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
import MessageHistory from './MessageHistory.vue'
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
import GroupPinnedMessage from './GroupPinnedMessage.vue'
@ -227,14 +228,18 @@ provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.ope
// statecomposable退 + template
const multiSelect = useMessageMultiSelect()
const voicePlayer = useVoicePlayer()
/** 切会话退出多选;避免上一会话的勾选状态泄漏到新会话type+targetId 一起监听,私聊与群聊 id 同号时也能触发) */
/** 切会话退出多选 + 停语音;避免上一会话的勾选 / 播放态泄漏到新会话type+targetId 一起监听,私聊与群聊 id 同号时也能触发) */
watch(
() => [
conversationStore.activeConversation?.type,
conversationStore.activeConversation?.targetId
],
() => multiSelect.exit()
() => {
multiSelect.exit()
voicePlayer.stop()
}
)
const messages = computed(() => conversationStore.getActiveMessages)

View File

@ -69,9 +69,11 @@ import UserAvatar from '@/views/im/home/components/user/UserAvatar.vue'
import MessageBubble from '../MessageBubble.vue'
import { parseMessage, type MergeMessage } from '@/views/im/utils/message'
import { formatMergeItemTime } from '@/views/im/utils/time'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
defineOptions({ name: 'ImMessageMergeDetailDialog' })
const voicePlayer = useVoicePlayer()
const visible = ref(false)
/** 嵌套层级栈,存 parsed payload 避免切层重 parse */
@ -106,9 +108,10 @@ function handleBack() {
}
}
/** 弹窗关闭:清栈,下次打开从顶层重新开始 */
/** 弹窗关闭:清栈 + 停语音,下次打开从顶层重新开始 */
function handleClose() {
stack.value = []
voicePlayer.stop()
}
</script>