From 0323566878342a3413e0abf574abe99b4b1cb3ca Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 7 May 2026 21:26:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=B6=88=E6=81=AF=E8=BD=AC=E5=8F=91=20v0.3=EF=BC=9A?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=AC=A1=E8=AF=84=E5=AE=A1=EF=BC=88=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E6=92=AD=E6=94=BE=E5=85=A8=E5=B1=80=E4=BA=92=E6=96=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新点的语音停掉旧的,对齐微信 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/home/composables/useVoicePlayer.ts | 81 +++++++++++++++++++ .../components/message/MessageBubble.vue | 42 ++++------ .../components/message/MessageHistory.vue | 5 +- .../components/message/MessagePanel.vue | 9 ++- .../forward/MessageMergeDetailDialog.vue | 5 +- 5 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 src/views/im/home/composables/useVoicePlayer.ts diff --git a/src/views/im/home/composables/useVoicePlayer.ts b/src/views/im/home/composables/useVoicePlayer.ts new file mode 100644 index 000000000..7892543f3 --- /dev/null +++ b/src/views/im/home/composables/useVoicePlayer.ts @@ -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(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 } +} diff --git a/src/views/im/home/pages/conversation/components/message/MessageBubble.vue b/src/views/im/home/pages/conversation/components/message/MessageBubble.vue index ecbaaa030..c4de65ce9 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageBubble.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageBubble.vue @@ -159,7 +159,7 @@ diff --git a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue index f300fa232..61b35700a 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue @@ -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() } } ) diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 2d1fbbc6c..3077025c5 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -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 // 模块级单例 state(composable);本组件仅做切会话退出 + 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) diff --git a/src/views/im/home/pages/conversation/components/message/forward/MessageMergeDetailDialog.vue b/src/views/im/home/pages/conversation/components/message/forward/MessageMergeDetailDialog.vue index da26b8960..6565c82f5 100644 --- a/src/views/im/home/pages/conversation/components/message/forward/MessageMergeDetailDialog.vue +++ b/src/views/im/home/pages/conversation/components/message/forward/MessageMergeDetailDialog.vue @@ -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() }