This commit is contained in:
sajjadtalkhabi
2026-05-13 03:06:59 +03:30
parent d2a577f254
commit b8ced45375
142 changed files with 3706 additions and 905 deletions
@@ -0,0 +1,364 @@
<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 { useUploadTemporaryMutation } 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: '' },
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
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 = useUploadTemporaryMutation()
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 = []
mediaRecorder = new window.MediaRecorder(mediaStream)
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 = props.kind === 'video' ? 'video/webm' : 'audio/webm'
const blob = new Blob(chunks, { type: mime })
const ext = props.kind === 'video' ? 'webm' : 'webm'
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,
})
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 {
width: 100%;
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>