✨ 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
parent
82d065c270
commit
0323566878
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue