145 lines
3.9 KiB
Vue
145 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
|
|
const emit = defineEmits<{ recorded: [blob: Blob] }>();
|
|
|
|
const recording = ref(false);
|
|
const mediaRecorder = ref<MediaRecorder | null>(null);
|
|
const chunks = ref<Blob[]>([]);
|
|
const duration = ref(0);
|
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
async function start() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
mediaRecorder.value = new MediaRecorder(stream);
|
|
chunks.value = [];
|
|
|
|
mediaRecorder.value.ondataavailable = (e) => chunks.value.push(e.data);
|
|
mediaRecorder.value.onstop = () => {
|
|
const blob = new Blob(chunks.value, { type: 'audio/webm' });
|
|
emit('recorded', blob);
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
};
|
|
|
|
mediaRecorder.value.start();
|
|
recording.value = true;
|
|
duration.value = 0;
|
|
timer = setInterval(() => duration.value++, 1000);
|
|
} catch {
|
|
// Microphone access denied
|
|
}
|
|
}
|
|
|
|
function stop() {
|
|
mediaRecorder.value?.stop();
|
|
recording.value = false;
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
}
|
|
|
|
function cancel() {
|
|
if (mediaRecorder.value?.state === 'recording') {
|
|
mediaRecorder.value.ondataavailable = null;
|
|
mediaRecorder.value.onstop = null;
|
|
mediaRecorder.value.stop();
|
|
}
|
|
recording.value = false;
|
|
duration.value = 0;
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
}
|
|
|
|
function formatTime(s: number) {
|
|
const m = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="voice-recorder">
|
|
<div v-if="recording" class="voice-recorder__active">
|
|
<span class="voice-recorder__dot" aria-hidden="true" />
|
|
<span class="voice-recorder__time meta">{{ formatTime(duration) }}</span>
|
|
<button class="voice-recorder__cancel" @click="cancel" aria-label="Отменить запись">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
<button class="voice-recorder__stop" @click="stop" aria-label="Остановить запись">
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<button v-else class="voice-recorder__btn" @click="start" aria-label="Записать голосовое сообщение">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.voice-recorder {
|
|
&__btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-muted);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
transition: color var(--transition-fast);
|
|
|
|
&:hover { color: var(--color-cream); }
|
|
}
|
|
|
|
&__active {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
&__dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--color-signal);
|
|
animation: pulse-signal 1s ease-in-out infinite;
|
|
}
|
|
|
|
&__time {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
&__cancel,
|
|
&__stop {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
|
|
&__cancel {
|
|
background: var(--color-surface-2);
|
|
color: var(--color-muted);
|
|
&:hover { color: var(--color-cream); }
|
|
}
|
|
|
|
&__stop {
|
|
background: var(--color-signal);
|
|
color: white;
|
|
&:hover { background: #a84e30; }
|
|
}
|
|
}
|
|
</style>
|