Files
banu-front/src/features/auth/components/studentRegister/MediaRecorder.vue
T
sajjadtalkhabi da34813b1d
Deploy banu-front / deploy (push) Successful in 1m28s
fix: bugs
2026-07-09 13:25:59 +03:30

393 lines
9.7 KiB
Vue

<template>
<div class="media-recorder" :class="`media-recorder--${kind}`">
<div class="media-recorder__frame">
<div v-if="previewUrl" class="media-recorder__preview">
<video
v-if="kind === 'video'"
ref="previewEl"
:src="previewUrl"
controls
playsinline
class="media-recorder__media"
/>
<audio
v-else
ref="previewEl"
:src="previewUrl"
controls
class="media-recorder__media media-recorder__media--audio"
/>
</div>
<div v-else-if="isRecording" class="media-recorder__live">
<video
v-if="kind === 'video'"
ref="liveEl"
autoplay
muted
playsinline
class="media-recorder__media"
/>
<div v-else class="media-recorder__pulse">
<SvgIcon name="paper-plane-right" :size="32" color="var(--color-primary)" />
</div>
<div class="media-recorder__timer">{{ formattedTime }}</div>
</div>
<div v-else class="media-recorder__empty">
<SvgIcon name="upload" :size="48" color="var(--color-thd-gray)" />
<p class="media-recorder__hint">{{ hintText }}</p>
</div>
</div>
<div class="media-recorder__actions">
<button
v-if="!isRecording && !previewUrl"
type="button"
class="media-recorder__btn media-recorder__btn--start"
:disabled="loading"
@click="onStart"
>
<span v-if="loading">در حال بارگذاری...</span>
<span v-else>شروع ضبط</span>
</button>
<button
v-if="isRecording"
type="button"
class="media-recorder__btn media-recorder__btn--stop"
@click="onStop"
>
پایان ضبط
</button>
<button
v-if="previewUrl && !loading"
type="button"
class="media-recorder__btn media-recorder__btn--reset"
@click="onReset"
>
ضبط دوباره
</button>
</div>
<p v-if="error" class="media-recorder__error">{{ error }}</p>
</div>
<PermissionModal v-if="isModal('RegisterPermissionModal')" />
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
const props = defineProps({
modelValue: { type: Object, default: null },
kind: {
type: String,
default: 'audio',
validator: (v) => ['audio', 'video'].includes(v),
},
maxSeconds: { type: Number, default: 120 },
context: { type: String, default: 'verification' },
subType: { type: String, default: '' },
purpose: { type: String, default: 'video' },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'uploaded'])
const { openModal, isModal } = useModal()
const previewUrl = ref(props.modelValue?.url || null)
const isRecording = ref(false)
const elapsedMs = ref(0)
const loading = ref(false)
const liveEl = ref(null)
const previewEl = ref(null)
let mediaRecorder = null
let mediaStream = null
let chunks = []
let timerHandle = null
let timerStart = 0
let recorderType = null
// The backend only accepts these extensions. Browsers can't record mp3/wav, but
// most can record into an mp4 container (→ .m4a audio / .mp4 video), so prefer a
// supported type whose extension the backend allows.
const ACCEPTED_TYPES = {
audio: [
{ mime: 'audio/mp4', ext: 'm4a' },
{ mime: 'audio/mpeg', ext: 'mp3' },
{ mime: 'audio/wav', ext: 'wav' },
],
video: [
{ mime: 'video/mp4', ext: 'mp4' },
{ mime: 'video/webm', ext: 'webm' },
],
}
const pickRecorderType = (kind) => {
const candidates = ACCEPTED_TYPES[kind] || []
const supported = candidates.find((c) => window.MediaRecorder?.isTypeSupported?.(c.mime))
if (supported) return supported
// Last-resort fallbacks so recording still works even if extensions differ.
return kind === 'video'
? { mime: 'video/webm', ext: 'webm' }
: { mime: 'audio/webm', ext: 'webm' }
}
const hintText = computed(() =>
props.kind === 'video' ? 'ویدیوی خود را ضبط کنید' : 'صدای خود را ضبط کنید'
)
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 uploadMutation = useUploadMediaMutation()
const stopTracks = () => {
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
}
const clearTimer = () => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
}
const onStart = async () => {
try {
const constraints = props.kind === 'video' ? { audio: true, video: true } : { audio: true }
mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
if (props.kind === 'video' && liveEl.value) {
liveEl.value.srcObject = mediaStream
}
chunks = []
recorderType = pickRecorderType(props.kind)
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => handleStop()
mediaRecorder.start()
isRecording.value = true
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
elapsedMs.value = Date.now() - timerStart
if (elapsedMs.value >= props.maxSeconds * 1000) onStop()
}, 250)
} catch {
openModal('RegisterPermissionModal', { kind: props.kind })
}
}
const onStop = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
clearTimer()
}
const handleStop = async () => {
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.${ext}`, { type: mime, lastModified: Date.now() })
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(blob)
loading.value = true
try {
const fd = objectToFormData({
file,
subType: props.subType || props.kind,
context: props.context,
...(props.purpose ? { purpose: props.purpose } : {}),
})
const response = await uploadMutation.mutateAsync(fd)
const payload = response?.data ?? response
const value = {
url: previewUrl.value,
uploadId: payload?.uploadId || payload?.id,
...payload,
}
emit('update:modelValue', value)
emit('uploaded', value)
} catch {
toast.error('بارگذاری فایل با خطا مواجه شد')
} finally {
loading.value = false
}
}
const onReset = () => {
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = null
emit('update:modelValue', null)
}
onBeforeUnmount(() => {
stopTracks()
clearTimer()
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
})
</script>
<style lang="scss" scoped>
.media-recorder {
display: flex;
flex-direction: column;
gap: 0.625rem;
width: 100%;
&__frame {
position: relative;
aspect-ratio: 3 / 4;
border-radius: 1rem;
background: #f3f4f6;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
&--audio &__frame {
aspect-ratio: 1;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-thd-gray);
}
&__hint {
font-family: var(--font-family-fa);
font-size: 0.75rem;
margin: 0;
}
&__live,
&__preview {
width: 100%;
height: 100%;
position: relative;
}
&__media {
width: 100%;
height: 100%;
object-fit: cover;
background: #000;
}
&__media--audio {
height: 2.5rem;
background: transparent;
position: absolute;
inset-block-end: 0.75rem;
inset-inline: 0.5rem;
width: calc(100% - 1rem);
}
&__pulse {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
animation: pulse 1.5s ease-in-out infinite;
}
&__timer {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
background: rgba(0, 0, 0, 60%);
color: #fff;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-family: var(--font-family-en);
font-size: 0.75rem;
}
&__actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
&__btn {
border: none;
border-radius: 9999px;
padding: 0.5rem 1rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--start {
background: var(--color-primary);
color: #fff;
}
&--stop {
background: var(--color-error);
color: #fff;
}
&--reset {
background: transparent;
color: var(--color-sec-gray);
border: 1px solid var(--color-thd-gray);
}
}
&__error {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
margin: 0;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style>