<template> <div :class="{ 'message-input-audio': true, 'message-input-audio-open': isAudioTouchBarShow, }" > <Icon class="audio-message-icon" :file="audioIcon" :size="'23px'" :hotAreaSize="'3px'" @onClick="switchAudio" /> <view v-if="props.isEnableAudio" class="audio-input-touch-bar" @touchstart="handleTouchStart" @longpress="handleLongPress" @touchmove="handleTouchMove" @touchend="handleTouchEnd" > <span>{{ TUITranslateService.t(`TUIChat.${touchBarText}`) }}</span> <view v-if="isRecording" class="record-modal" > <div class="red-mask" /> <view class="float-element moving-slider" /> <view class="float-element modal-title"> {{ TUITranslateService.t(`TUIChat.${modalText}`) }} </view> </view> </view> </div> </template> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from '../../../adapter-vue'; import { TUIStore, StoreName, TUIChatService, SendMessageParams, IConversationModel, TUITranslateService, } from '@tencentcloud/chat-uikit-engine'; import { TUIGlobal } from '@tencentcloud/universal-api'; import Icon from '../../common/Icon.vue'; import audioIcon from '../../../assets/icon/audio.svg'; import { Toast, TOAST_TYPE } from '../../common/Toast/index'; import { throttle } from '../../../utils/lodash'; import { isEnabledMessageReadReceiptGlobal } from '../utils/utils'; import { InputDisplayType } from '../../../interface'; interface IProps { isEnableAudio: boolean; } interface IEmits { (e: 'changeDisplayType', type: InputDisplayType): void; } interface RecordResult { tempFilePath: string; duration?: number; fileSize?: number; } type TouchBarText = '按住说话' | '抬起发送' | '抬起取消'; type ModalText = '正在录音' | '继续上滑可取消' | '松开手指 取消发送'; const emits = defineEmits<IEmits>(); const props = withDefaults(defineProps<IProps>(), { isEnableAudio: false, }); let recordTime: number = 0; let isManualCancelBySlide = false; let recordTimer: number | undefined; let firstTouchPageY: number = -1; let isFingerTouchingScreen = false; let isFirstAuthrizedRecord = false; const recorderManager = TUIGlobal?.getRecorderManager(); const isRecording = ref(false); const touchBarText = ref<TouchBarText>('按住说话'); const modalText = ref<ModalText>('正在录音'); const isAudioTouchBarShow = ref<boolean>(false); const currentConversation = ref<IConversationModel>(); const recordConfig = { // Duration of the recording, in ms, with a maximum value of 600000 (10 minutes) duration: 60000, // Sampling rate sampleRate: 44100, // Number of recording channels numberOfChannels: 1, // Encoding bit rate encodeBitRate: 192000, // Audio format // Select this format to create audio messages that can be interoperable across all chat platforms (Android, iOS, WeChat Mini Programs, and Web). format: 'mp3', }; function switchAudio() { emits('changeDisplayType', props.isEnableAudio ? 'editor' : 'audio'); } onMounted(() => { // Register events for the audio recording manager recorderManager.onStart(onRecorderStart); recorderManager.onStop(onRecorderStop); recorderManager.onError(onRecorderError); TUIStore.watch(StoreName.CONV, { currentConversation: onCurrentConverstaionUpdated, }); }); onUnmounted(() => { TUIStore.unwatch(StoreName.CONV, { currentConversation: onCurrentConverstaionUpdated, }); }); function onCurrentConverstaionUpdated(conversation: IConversationModel) { currentConversation.value = conversation; } function initRecorder() { initRecorderData(); initRecorderView(); } function initRecorderView() { isRecording.value = false; touchBarText.value = '按住说话'; modalText.value = '正在录音'; } function initRecorderData(options?: { hasError: boolean }) { clearInterval(recordTimer); recordTimer = undefined; recordTime = 0; firstTouchPageY = -1; isManualCancelBySlide = false; if (!options?.hasError) { recorderManager.stop(); } } function handleTouchStart() { if (isFingerTouchingScreen) { // Compatibility: Ignore the recording generated by the user's first authorization on the APP. isFirstAuthrizedRecord = true; } } function handleLongPress() { isFingerTouchingScreen = true; recorderManager.start(recordConfig); } const handleTouchMove = throttle(function (e) { if (isRecording.value) { const pageY = e.changedTouches[e.changedTouches.length - 1].pageY; if (firstTouchPageY < 0) { firstTouchPageY = pageY; } const offset = (firstTouchPageY as number) - pageY; if (offset > 150) { touchBarText.value = '抬起取消'; modalText.value = '松开手指 取消发送'; isManualCancelBySlide = true; } else if (offset > 50) { touchBarText.value = '抬起发送'; modalText.value = '继续上滑可取消'; isManualCancelBySlide = false; } else { touchBarText.value = '抬起发送'; modalText.value = '正在录音'; isManualCancelBySlide = false; } } }, 100); function handleTouchEnd() { isFingerTouchingScreen = false; recorderManager.stop(); } function onRecorderStart() { if (!isFingerTouchingScreen) { // If recording starts but the finger leaves the screen, // it means that the initial authorization popup interrupted the recording and it should be ignored. isFirstAuthrizedRecord = true; recorderManager.stop(); return; } recordTimer = setInterval(() => { recordTime += 1; }, 1000); touchBarText.value = '抬起发送'; isRecording.value = true; } function onRecorderStop(res: RecordResult) { if (isFirstAuthrizedRecord) { // Compatibility: Ignore the recording generated by the user's first authorization on WeChat. This is not applicable to the APP. isFirstAuthrizedRecord = false; initRecorder(); return; } if (isManualCancelBySlide || !isRecording.value) { initRecorder(); return; } clearInterval(recordTimer); /** * Compatible with uniapp for building apps * Compatible with uniapp voice messages without duration * Duration and fileSize need to be supplemented by the user * File size = (Audio bitrate) * Length of time (in seconds) / 8 * res.tempFilePath stores the temporary path of the recorded audio file */ const tempFilePath = res.tempFilePath; const duration = res.duration ? res.duration : recordTime * 1000; const fileSize = res.fileSize ? res.fileSize : ((48 * recordTime) / 8) * 1024; if (duration < 1000) { Toast({ message: '录音时间太短', type: TOAST_TYPE.NORMAL, duration: 1500, }); } else { const options = { to: currentConversation?.value?.groupProfile?.groupID || currentConversation?.value?.userProfile?.userID, conversationType: currentConversation?.value?.type, payload: { file: { duration, tempFilePath, fileSize } }, needReadReceipt: isEnabledMessageReadReceiptGlobal(), } as SendMessageParams; TUIChatService?.sendAudioMessage(options); } initRecorder(); } function onRecorderError() { initRecorderData({ hasError: true }); initRecorderView(); } </script> <style lang="scss" scoped> @import "../../../assets/styles/common"; .message-input-audio { display: flex; flex-direction: row; align-items: center; .audio-message-icon { margin-right: 3px; } .audio-input-touch-bar { height: 39px; flex: 1; border-radius: 10px; display: flex; flex-direction: row; justify-content: center; align-items: center; background-color: #fff; .record-modal { height: 300rpx; width: 60vw; background-color: rgba(0, 0, 0, 0.8); position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 9999; border-radius: 24rpx; display: flex; flex-direction: column; overflow: hidden; .red-mask { position: absolute; inset: 0; background-color: rgba(#ff3e48, 0.5); opacity: 0; transition: opacity 10ms linear; z-index: 1; } .moving-slider { margin: 10vw; width: 40rpx; height: 16rpx; border-radius: 4rpx; background-color: #006fff; animation: loading 1s ease-in-out infinite alternate; z-index: 2; } .float-element { position: relative; z-index: 2; } } @keyframes loading { 0% { transform: translate(0, 0); } 100% { transform: translate(30vw, 0); background-color: #f5634a; width: 40px; } } .modal-title { text-align: center; color: #fff; } } &-open { flex: 1; } } </style>