563 lines
13 KiB
Vue
563 lines
13 KiB
Vue
<template>
|
|
<div class="voice-recorder">
|
|
<div class="voice-recorder__stage">
|
|
<button
|
|
v-if="!isRecording && !audioUrl"
|
|
type="button"
|
|
class="voice-recorder__main-btn"
|
|
:disabled="disabled"
|
|
@click="startRecording"
|
|
>
|
|
<SvgIcon name="microphone" :size="48" color="transparent" />
|
|
</button>
|
|
|
|
<button
|
|
v-else-if="isRecording"
|
|
type="button"
|
|
class="voice-recorder__main-btn voice-recorder__main-btn--recording"
|
|
@click="stopRecording"
|
|
>
|
|
<SvgIcon name="close" :size="40" color="var(--color-error)" />
|
|
</button>
|
|
|
|
<div v-else class="voice-recorder__player">
|
|
<button type="button" class="voice-recorder__play-btn" @click="togglePlay">
|
|
<SvgIcon :name="isPlaying ? 'pause' : 'play'" :size="24" color="#848484" />
|
|
</button>
|
|
<div
|
|
ref="trackEl"
|
|
class="voice-recorder__track"
|
|
@pointerdown="onSeekStart"
|
|
@pointermove="onSeekMove"
|
|
@pointerup="onSeekEnd"
|
|
@pointercancel="onSeekEnd"
|
|
>
|
|
<div class="voice-recorder__track-fill" :style="{ width: `${progress}%` }" />
|
|
<span class="voice-recorder__knob" :style="{ left: `${progress}%` }" />
|
|
</div>
|
|
<span class="voice-recorder__time">{{ timeLabel }}</span>
|
|
<audio
|
|
ref="audioEl"
|
|
:src="audioUrl"
|
|
class="voice-recorder__audio"
|
|
@ended="onEnded"
|
|
@timeupdate="onTimeUpdate"
|
|
@loadedmetadata="onLoadedMetadata"
|
|
/>
|
|
</div>
|
|
|
|
<p v-if="isRecording" class="voice-recorder__status">
|
|
<span class="voice-recorder__dot" />
|
|
در حال ضبط...
|
|
<span class="voice-recorder__timer">{{ formattedTime }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="voice-recorder__actions">
|
|
<button
|
|
v-if="audioUrl && !isRecording && !disabled"
|
|
type="button"
|
|
class="voice-recorder__btn"
|
|
:title="'ضبط مجدد'"
|
|
@click="resetAndStart"
|
|
>
|
|
<SvgIcon name="microphone" :size="20" color="transparent" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="voice-recorder__btn"
|
|
:disabled="isRecording || disabled"
|
|
:title="'بارگذاری فایل'"
|
|
@click="triggerFilePicker"
|
|
v-if="canUpload"
|
|
>
|
|
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
|
|
</button>
|
|
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
accept="audio/*"
|
|
class="voice-recorder__file-input"
|
|
@change="onFileSelected"
|
|
/>
|
|
</div>
|
|
|
|
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
|
|
</div>
|
|
|
|
<PermissionModal v-if="isModal('VoiceRecorderPermissionModal')" />
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from 'vue3-toastify'
|
|
import useModal from '@/composables/useModal'
|
|
import { PermissionModal } from '@/features/auth'
|
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: [Object, File, String, null], default: null },
|
|
maxSeconds: { type: Number, default: 600 },
|
|
maxSizeKb: { type: Number, default: 25_600 },
|
|
disabled: { type: Boolean, default: false },
|
|
error: { type: String, default: '' },
|
|
canUpload: { type: Boolean, default: false },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'recorded'])
|
|
|
|
const { openModal, isModal } = useModal()
|
|
|
|
const audioUrl = ref(
|
|
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
|
|
)
|
|
const audioFile = ref(null)
|
|
const isRecording = ref(false)
|
|
const isPlaying = ref(false)
|
|
const elapsedMs = ref(0)
|
|
const progress = ref(0)
|
|
const duration = ref(0)
|
|
const currentSeconds = ref(0)
|
|
const audioEl = ref(null)
|
|
const trackEl = ref(null)
|
|
const fileInput = ref(null)
|
|
let seeking = false
|
|
|
|
let mediaRecorder = null
|
|
let mediaStream = null
|
|
let chunks = []
|
|
let timerHandle = null
|
|
let timerStart = 0
|
|
let recorderType = null
|
|
|
|
// The backend only accepts these audio extensions. Browsers can't record mp3/wav,
|
|
// but most can record into an mp4 container (→ .m4a), so prefer a supported type
|
|
// whose extension the backend allows; fall back to webm only as a last resort.
|
|
const ACCEPTED_AUDIO_TYPES = [
|
|
{ mime: 'audio/mp4', ext: 'm4a' },
|
|
{ mime: 'audio/mpeg', ext: 'mp3' },
|
|
{ mime: 'audio/wav', ext: 'wav' },
|
|
]
|
|
|
|
const pickRecorderType = () => {
|
|
const supported = ACCEPTED_AUDIO_TYPES.find((c) =>
|
|
window.MediaRecorder?.isTypeSupported?.(c.mime)
|
|
)
|
|
return supported || { mime: 'audio/webm', ext: 'webm' }
|
|
}
|
|
|
|
const formattedTime = computed(() => {
|
|
const s = Math.floor(elapsedMs.value / 1000)
|
|
const mm = String(Math.floor(s / 60)).padStart(2, '0')
|
|
const ss = String(s % 60).padStart(2, '0')
|
|
return `${mm}:${ss}`
|
|
})
|
|
|
|
const stopTracks = () => {
|
|
if (mediaStream) {
|
|
mediaStream.getTracks().forEach((t) => t.stop())
|
|
mediaStream = null
|
|
}
|
|
}
|
|
|
|
const clearTimer = () => {
|
|
if (timerHandle) {
|
|
clearInterval(timerHandle)
|
|
timerHandle = null
|
|
}
|
|
}
|
|
|
|
const revokePreview = () => {
|
|
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
|
|
URL.revokeObjectURL(audioUrl.value)
|
|
}
|
|
}
|
|
|
|
const setRecording = (file) => {
|
|
revokePreview()
|
|
audioFile.value = file
|
|
audioUrl.value = URL.createObjectURL(file)
|
|
progress.value = 0
|
|
duration.value = 0
|
|
currentSeconds.value = 0
|
|
isPlaying.value = false
|
|
emit('update:modelValue', file)
|
|
emit('recorded', file)
|
|
}
|
|
|
|
const startRecording = async () => {
|
|
try {
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
chunks = []
|
|
recorderType = pickRecorderType()
|
|
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data?.size > 0) chunks.push(e.data)
|
|
}
|
|
mediaRecorder.onstop = () => {
|
|
isRecording.value = false
|
|
stopTracks()
|
|
const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
|
|
const ext = recorderType?.ext || 'webm'
|
|
const blob = new Blob(chunks, { type: mime })
|
|
const file = new File([blob], `recording-${Date.now()}.${ext}`, {
|
|
type: mime,
|
|
lastModified: Date.now(),
|
|
})
|
|
setRecording(file)
|
|
}
|
|
mediaRecorder.start()
|
|
isRecording.value = true
|
|
elapsedMs.value = 0
|
|
timerStart = Date.now()
|
|
timerHandle = setInterval(() => {
|
|
elapsedMs.value = Date.now() - timerStart
|
|
if (elapsedMs.value >= props.maxSeconds * 1000) stopRecording()
|
|
}, 250)
|
|
} catch {
|
|
openModal('VoiceRecorderPermissionModal', { kind: 'audio' })
|
|
}
|
|
}
|
|
|
|
const stopRecording = () => {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
mediaRecorder.stop()
|
|
}
|
|
clearTimer()
|
|
}
|
|
|
|
const resetAndStart = () => {
|
|
if (isPlaying.value) {
|
|
audioEl.value?.pause()
|
|
isPlaying.value = false
|
|
}
|
|
revokePreview()
|
|
audioFile.value = null
|
|
audioUrl.value = null
|
|
progress.value = 0
|
|
emit('update:modelValue', null)
|
|
startRecording()
|
|
}
|
|
|
|
const triggerFilePicker = () => fileInput.value?.click()
|
|
|
|
const onFileSelected = (event) => {
|
|
const file = event.target?.files?.[0]
|
|
if (event.target) event.target.value = ''
|
|
if (!file) return
|
|
if (file.size > props.maxSizeKb * 1024) {
|
|
toast.error(`حجم فایل صوتی نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
|
|
return
|
|
}
|
|
setRecording(file)
|
|
}
|
|
|
|
const togglePlay = () => {
|
|
if (!audioEl.value) return
|
|
if (audioEl.value.paused) {
|
|
audioEl.value.play().then(() => {
|
|
isPlaying.value = true
|
|
})
|
|
} else {
|
|
audioEl.value.pause()
|
|
isPlaying.value = false
|
|
}
|
|
}
|
|
|
|
const onEnded = () => {
|
|
isPlaying.value = false
|
|
progress.value = 0
|
|
currentSeconds.value = 0
|
|
}
|
|
|
|
const onTimeUpdate = () => {
|
|
const a = audioEl.value
|
|
if (!a || !Number.isFinite(duration.value) || duration.value <= 0) return
|
|
currentSeconds.value = a.currentTime
|
|
progress.value = (a.currentTime / duration.value) * 100
|
|
}
|
|
|
|
// MediaRecorder blobs report `Infinity` duration in Chromium until the element
|
|
// is seeked past the end once — force that, then rewind.
|
|
const onLoadedMetadata = () => {
|
|
const a = audioEl.value
|
|
if (!a) return
|
|
if (Number.isFinite(a.duration)) {
|
|
duration.value = a.duration
|
|
return
|
|
}
|
|
const restore = () => {
|
|
if (!Number.isFinite(a.duration)) return
|
|
duration.value = a.duration
|
|
a.currentTime = 0
|
|
a.removeEventListener('timeupdate', restore)
|
|
}
|
|
a.addEventListener('timeupdate', restore)
|
|
a.currentTime = 1e10
|
|
}
|
|
|
|
const formatClock = (secs) => {
|
|
if (!Number.isFinite(secs) || secs < 0) return '00:00'
|
|
const total = Math.floor(secs)
|
|
const h = Math.floor(total / 3600)
|
|
const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
|
|
const ss = String(total % 60).padStart(2, '0')
|
|
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
|
}
|
|
|
|
const timeLabel = computed(() =>
|
|
formatClock(isPlaying.value || currentSeconds.value > 0 ? currentSeconds.value : duration.value)
|
|
)
|
|
|
|
const seekTo = (clientX) => {
|
|
const a = audioEl.value
|
|
const track = trackEl.value
|
|
if (!a || !track || !Number.isFinite(duration.value) || duration.value <= 0) return
|
|
const rect = track.getBoundingClientRect()
|
|
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
|
|
a.currentTime = ratio * duration.value
|
|
currentSeconds.value = a.currentTime
|
|
progress.value = ratio * 100
|
|
}
|
|
|
|
const onSeekStart = (event) => {
|
|
seeking = true
|
|
trackEl.value?.setPointerCapture?.(event.pointerId)
|
|
seekTo(event.clientX)
|
|
}
|
|
|
|
const onSeekMove = (event) => {
|
|
if (seeking) seekTo(event.clientX)
|
|
}
|
|
|
|
const onSeekEnd = () => {
|
|
seeking = false
|
|
}
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
if (val == null) {
|
|
revokePreview()
|
|
audioFile.value = null
|
|
audioUrl.value = null
|
|
progress.value = 0
|
|
}
|
|
}
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
stopRecording()
|
|
stopTracks()
|
|
clearTimer()
|
|
revokePreview()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.voice-recorder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 1rem;
|
|
background: #f3f4f6;
|
|
border-radius: 1.5rem;
|
|
min-height: 18rem;
|
|
justify-content: space-between;
|
|
height: 100%;
|
|
|
|
&__stage {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.625rem;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
&__main-btn {
|
|
width: 8rem;
|
|
height: 8rem;
|
|
border-radius: 9999px;
|
|
background: #fff;
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
|
|
&:hover:enabled {
|
|
transform: scale(1.02);
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
|
|
}
|
|
|
|
&:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
&--recording {
|
|
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
|
|
animation: pulse 1.4s infinite;
|
|
}
|
|
}
|
|
|
|
&__player {
|
|
direction: ltr;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
width: 100%;
|
|
padding: 1.25rem 1.5rem;
|
|
background: #fff;
|
|
border-radius: 1rem;
|
|
}
|
|
|
|
&__play-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: transform 0.15s ease;
|
|
|
|
&:hover {
|
|
transform: scale(1.08);
|
|
}
|
|
}
|
|
|
|
&__track {
|
|
position: relative;
|
|
flex: 1;
|
|
height: 2px;
|
|
border-radius: 9999px;
|
|
background: #e8e8e8;
|
|
cursor: pointer;
|
|
touch-action: none;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: -0.625rem 0;
|
|
}
|
|
}
|
|
|
|
&__track-fill {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
border-radius: 9999px;
|
|
background: #5d5d5d;
|
|
}
|
|
|
|
&__knob {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 0.625rem;
|
|
height: 0.625rem;
|
|
border-radius: 9999px;
|
|
background: #4b4b4b;
|
|
pointer-events: none;
|
|
}
|
|
|
|
&__time {
|
|
font-family: var(--font-family-en);
|
|
font-size: 0.75rem;
|
|
color: #b5b5b5;
|
|
flex-shrink: 0;
|
|
min-width: 3rem;
|
|
text-align: right;
|
|
}
|
|
|
|
&__audio {
|
|
display: none;
|
|
}
|
|
|
|
&__status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.875rem;
|
|
color: var(--color-error);
|
|
margin: 0;
|
|
}
|
|
|
|
&__dot {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 9999px;
|
|
background: var(--color-error);
|
|
}
|
|
|
|
&__timer {
|
|
margin-inline-start: 0.5rem;
|
|
font-family: var(--font-family-en);
|
|
color: #5d5d5d;
|
|
}
|
|
|
|
&__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
&__btn {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 9999px;
|
|
background: #fff;
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
|
|
transition: transform 0.15s ease;
|
|
|
|
&:hover:enabled {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
&:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
|
|
&__file-input {
|
|
display: none;
|
|
}
|
|
|
|
&__error {
|
|
margin: 0;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.75rem;
|
|
color: var(--color-error);
|
|
}
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
|
|
}
|
|
|
|
50% {
|
|
box-shadow: 0 0 0 10px rgba(204, 40, 49, 18%);
|
|
}
|
|
}
|
|
</style>
|