| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { |
| | | computed, |
| | | onBeforeUnmount, |
| | | onMounted, |
| | | onUnmounted, |
| | | ref, |
| | | useTemplateRef, |
| | | watch |
| | | } from 'vue' |
| | | |
| | | import { Button, message } from 'ant-design-vue' |
| | | |
| | | import { formatSeconds } from '#/views/im/utils/time' |
| | | |
| | | defineOptions({ name: 'ImVoiceRecorder' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | maxDuration?: number // æé¿å½å¶ç§æ° |
| | | modelValue: boolean // æ¯å¦æ¾ç¤º |
| | | }>(), |
| | | { |
| | | maxDuration: 60 |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | send: [payload: { blob: Blob; duration: number; extension: string; mimeType: string }] // å½å¶å®ææ°æ® |
| | | 'update:modelValue': [value: boolean] |
| | | }>() |
| | | |
| | | const VOICE_MIME_TYPE_OPTIONS = [ |
| | | { extension: 'webm', mimeType: 'audio/webm;codecs=opus' }, |
| | | { extension: 'webm', mimeType: 'audio/webm' }, |
| | | { extension: 'm4a', mimeType: 'audio/mp4' }, |
| | | { extension: 'ogg', mimeType: 'audio/ogg;codecs=opus' }, |
| | | { extension: 'ogg', mimeType: 'audio/ogg' } |
| | | ] |
| | | |
| | | const visible = computed({ |
| | | get: () => props.modelValue, |
| | | set: (v) => emit('update:modelValue', v) |
| | | }) |
| | | |
| | | const rootRef = useTemplateRef<HTMLDivElement>('rootRef') |
| | | |
| | | /** å½å¶ç¶æ */ |
| | | type Status = 'idle' | 'preview' | 'recording' |
| | | const status = ref<Status>('idle') |
| | | const duration = ref(0) |
| | | const previewUrl = ref('') |
| | | |
| | | let mediaRecorder: MediaRecorder | null = null |
| | | let audioChunks: Blob[] = [] |
| | | let mediaStream: MediaStream | null = null |
| | | let timer: null | ReturnType<typeof setInterval> = null |
| | | let recordedBlob: Blob | null = null |
| | | let discarding = false |
| | | let recordingMimeType = '' |
| | | let recordingExtension = '' |
| | | let recordedMimeType = '' |
| | | let recordedExtension = '' |
| | | |
| | | /** 计æ¶å¨ææ¡ */ |
| | | const timerText = computed(() => formatSeconds(duration.value)) |
| | | |
| | | /** çå¬é¢æ¿æ¾ç¤ºç¶æ */ |
| | | watch(visible, (v) => { |
| | | if (v) { |
| | | document.addEventListener('click', handleDocumentClick) |
| | | } else { |
| | | document.removeEventListener('click', handleDocumentClick) |
| | | resetAll() |
| | | } |
| | | }) |
| | | |
| | | /** å¤çå¤é¨ç¹å» */ |
| | | function handleDocumentClick(e: MouseEvent) { |
| | | if (!props.modelValue || !rootRef.value) { |
| | | return |
| | | } |
| | | if (rootRef.value.contains(e.target as Node)) { |
| | | return |
| | | } |
| | | if (status.value !== 'idle') { |
| | | return |
| | | } |
| | | visible.value = false |
| | | } |
| | | |
| | | /** è·åæ¯æçå½é³æ ¼å¼ */ |
| | | function getSupportedVoiceMimeType() { |
| | | if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) { |
| | | return undefined |
| | | } |
| | | return VOICE_MIME_TYPE_OPTIONS.find((item) => MediaRecorder.isTypeSupported(item.mimeType)) |
| | | } |
| | | |
| | | /** è·åå½é³æä»¶åç¼ */ |
| | | function getVoiceExtension(mimeType: string) { |
| | | const normalizedMimeType = mimeType.split(';')[0] |
| | | if (normalizedMimeType === 'audio/mp4') { |
| | | return 'm4a' |
| | | } |
| | | if (normalizedMimeType === 'audio/ogg') { |
| | | return 'ogg' |
| | | } |
| | | return 'webm' |
| | | } |
| | | |
| | | /** å建å½é³å¨ */ |
| | | function createVoiceRecorder(stream: MediaStream) { |
| | | const supportedMimeType = getSupportedVoiceMimeType() |
| | | const recorder = supportedMimeType |
| | | ? new MediaRecorder(stream, { mimeType: supportedMimeType.mimeType }) |
| | | : new MediaRecorder(stream) |
| | | recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || '' |
| | | recordingExtension = supportedMimeType?.extension || (recordingMimeType ? getVoiceExtension(recordingMimeType) : '') |
| | | return recorder |
| | | } |
| | | |
| | | /** å¼å§å½å¶ */ |
| | | async function startRecord() { |
| | | if (typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia) { |
| | | message.error('å½åæµè§å¨ä¸æ¯æå½é³ï¼éè¦ HTTPS æ localhostï¼') |
| | | return |
| | | } |
| | | try { |
| | | mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) |
| | | } catch { |
| | | message.error('æ æ³è·å麦å
飿é') |
| | | return |
| | | } |
| | | audioChunks = [] |
| | | discarding = false |
| | | try { |
| | | mediaRecorder = createVoiceRecorder(mediaStream) |
| | | } catch { |
| | | cleanupStream() |
| | | message.error('å½åæµè§å¨ä¸æ¯æå½é³æ ¼å¼') |
| | | return |
| | | } |
| | | mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => { |
| | | if (event.data.size > 0) { |
| | | audioChunks.push(event.data) |
| | | } |
| | | }) |
| | | mediaRecorder.addEventListener('stop', () => { |
| | | if (discarding) { |
| | | return |
| | | } |
| | | recordedMimeType = recordingMimeType || mediaRecorder?.mimeType || audioChunks[0]?.type || 'audio/webm' |
| | | recordedExtension = recordingExtension || getVoiceExtension(recordedMimeType) |
| | | recordedBlob = new Blob(audioChunks, { type: recordedMimeType }) |
| | | previewUrl.value = URL.createObjectURL(recordedBlob) |
| | | status.value = 'preview' |
| | | }) |
| | | mediaRecorder.start() |
| | | status.value = 'recording' |
| | | duration.value = 0 |
| | | timer = setInterval(() => { |
| | | duration.value++ |
| | | if (duration.value >= props.maxDuration) { |
| | | stopRecord() |
| | | } |
| | | }, 1000) |
| | | } |
| | | |
| | | /** 忢å½å¶ */ |
| | | function stopRecord() { |
| | | if (duration.value < 1) { |
| | | message.warning('å½é³æ¶é´å¤ªç') |
| | | resetAll() |
| | | return |
| | | } |
| | | if (mediaRecorder && mediaRecorder.state !== 'inactive') { |
| | | mediaRecorder.stop() |
| | | } |
| | | cleanupStream() |
| | | if (timer) { |
| | | clearInterval(timer) |
| | | timer = null |
| | | } |
| | | } |
| | | |
| | | /** éæ°å½å¶ */ |
| | | function restart() { |
| | | clearPreview() |
| | | duration.value = 0 |
| | | status.value = 'idle' |
| | | } |
| | | |
| | | /** åéå½é³ */ |
| | | function handleSend() { |
| | | if (!recordedBlob) { |
| | | return |
| | | } |
| | | emit('send', { |
| | | blob: recordedBlob, |
| | | duration: duration.value, |
| | | extension: recordedExtension, |
| | | mimeType: recordedMimeType || recordedBlob.type |
| | | }) |
| | | visible.value = false |
| | | } |
| | | |
| | | /** åæ¶å½å¶ */ |
| | | function handleCancel() { |
| | | visible.value = false |
| | | } |
| | | |
| | | /** éç½®å½å¶èµæº */ |
| | | function resetAll() { |
| | | discarding = true |
| | | if (mediaRecorder && mediaRecorder.state !== 'inactive') { |
| | | mediaRecorder.stop() |
| | | } |
| | | cleanupStream() |
| | | if (timer) { |
| | | clearInterval(timer) |
| | | timer = null |
| | | } |
| | | audioChunks = [] |
| | | duration.value = 0 |
| | | status.value = 'idle' |
| | | recordingMimeType = '' |
| | | recordingExtension = '' |
| | | recordedMimeType = '' |
| | | recordedExtension = '' |
| | | clearPreview() |
| | | } |
| | | |
| | | /** éæ¾é¢è§é³é¢ */ |
| | | function clearPreview() { |
| | | recordedBlob = null |
| | | if (previewUrl.value) { |
| | | URL.revokeObjectURL(previewUrl.value) |
| | | previewUrl.value = '' |
| | | } |
| | | } |
| | | |
| | | /** å
³é麦å
é£éé */ |
| | | function cleanupStream() { |
| | | mediaStream?.getTracks().forEach((t) => t.stop()) |
| | | mediaStream = null |
| | | } |
| | | |
| | | onMounted(() => { |
| | | if (props.modelValue) { |
| | | document.addEventListener('click', handleDocumentClick) |
| | | } |
| | | }) |
| | | |
| | | onBeforeUnmount(resetAll) |
| | | |
| | | onUnmounted(() => { |
| | | document.removeEventListener('click', handleDocumentClick) |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- è¯é³å½å¶é¢æ¿ --> |
| | | <div |
| | | v-if="visible" |
| | | ref="rootRef" |
| | | class="im-popover-arrow absolute z-100 w-80 p-4 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]" |
| | | @click.stop |
| | | > |
| | | <div class="flex flex-col items-center gap-3"> |
| | | <!-- å½å¶æ¶é¿ --> |
| | | <div class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]"> |
| | | {{ timerText }} |
| | | </div> |
| | | |
| | | <!-- ç¶æææ¡ --> |
| | | <div class="text-13px text-[var(--ant-color-text-secondary)]"> |
| | | <span v-if="status === 'idle'">ç¹å»ä¸æ¹æé®å¼å§å½å¶</span> |
| | | <span v-else-if="status === 'recording'">å½å¶ä¸ï¼æé¿ {{ maxDuration }} ç§</span> |
| | | <span v-else>å½å¶å®æï¼å¯è¯å¬ååé</span> |
| | | </div> |
| | | |
| | | <!-- å½å¶ç¶æ --> |
| | | <div |
| | | v-if="status !== 'preview'" |
| | | class="w-12 h-12 rounded-full bg-[var(--ant-color-border)]" |
| | | :class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording' }" |
| | | ></div> |
| | | <audio v-else :src="previewUrl" controls class="w-full"></audio> |
| | | </div> |
| | | |
| | | <!-- æä½æé® --> |
| | | <div class="flex justify-end gap-2 mt-3"> |
| | | <template v-if="status === 'idle'"> |
| | | <Button size="small" @click="handleCancel">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="startRecord">å¼å§å½å¶</Button> |
| | | </template> |
| | | <template v-else-if="status === 'recording'"> |
| | | <Button size="small" @click="handleCancel">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="stopRecord">忢å½å¶</Button> |
| | | </template> |
| | | <template v-else> |
| | | <Button size="small" @click="handleCancel">åæ¶</Button> |
| | | <Button size="small" @click="restart">éæ°å½å¶</Button> |
| | | <Button size="small" type="primary" @click="handleSend">åé</Button> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* åºé¨å°ä¸è§ */ |
| | | .im-popover-arrow::after { |
| | | content: ''; |
| | | position: absolute; |
| | | top: calc(100% - 1px); |
| | | left: 110px; |
| | | border-style: solid; |
| | | border-width: 6px 6px 0 6px; |
| | | border-color: var(--ant-color-bg-container) transparent transparent transparent; |
| | | filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08)); |
| | | } |
| | | |
| | | /* å½é³ä¸çèå²å¨ç» */ |
| | | .im-voice-recorder__pulse { |
| | | animation: im-voice-pulse 1s infinite; |
| | | } |
| | | |
| | | @keyframes im-voice-pulse { |
| | | 0% { |
| | | box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6); |
| | | } |
| | | 70% { |
| | | box-shadow: 0 0 0 20px rgba(245, 108, 108, 0); |
| | | } |
| | | 100% { |
| | | box-shadow: 0 0 0 0 rgba(245, 108, 108, 0); |
| | | } |
| | | } |
| | | </style> |