Files
Evoke/src/lib/components/voice/VoiceRecorder.svelte
T

141 lines
4.1 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
interface Props {
channelId: string;
}
let { channelId }: Props = $props();
let isRecording = $state(false);
let duration = $state(0);
let micError = $state<string | null>(null);
let mediaRecorder: MediaRecorder | null = $state(null);
let audioChunks: Blob[] = [];
let stream: MediaStream | null = null;
let startTime = 0;
let timerId: ReturnType<typeof setInterval> | null = null;
const MIN_DURATION = 0.3;
async function startRecording() {
micError = null;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType });
audioChunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.onstop = async () => {
const d = (Date.now() - startTime) / 1000;
if (d < MIN_DURATION) {
stream?.getTracks().forEach((t) => t.stop());
return;
}
const blob = new Blob(audioChunks, { type: mimeType });
const file = new File([blob], `voice-${Date.now()}.webm`, { type: mimeType });
const formData = new FormData();
formData.append('type', 'voice');
formData.append('duration', String(Math.round(d)));
formData.append('audio', file);
try {
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? 'Upload failed');
}
} catch (err) {
console.error('Upload failed', err);
}
audioChunks = [];
duration = 0;
};
mediaRecorder.start(250);
startTime = Date.now();
timerId = setInterval(() => {
duration = (Date.now() - startTime) / 1000;
}, 100);
isRecording = true;
} catch (err) {
micError = err instanceof Error ? err.message : 'Microphone access denied';
}
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
if (timerId) {
clearInterval(timerId);
timerId = null;
}
stream?.getTracks().forEach((t) => t.stop());
stream = null;
mediaRecorder = null;
isRecording = false;
}
function handlePointerDown(e: PointerEvent) {
e.preventDefault();
startRecording();
}
function handlePointerUp(e: PointerEvent) {
e.preventDefault();
if (isRecording) stopRecording();
}
onMount(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
startRecording();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
if (isRecording) stopRecording();
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
if (isRecording) stopRecording();
};
});
</script>
<div class="voice-ptt-container flex flex-col items-center gap-2">
{#if micError}
<p class="text-sm text-destructive">{micError}</p>
{/if}
<button
type="button"
class="ptt-button h-20 w-20 rounded-full border-none font-bold text-white cursor-pointer select-none touch-manipulation transition-transform
{isRecording ? 'bg-destructive animate-pulse' : 'bg-muted-foreground/80 hover:bg-muted-foreground'}"
onpointerdown={handlePointerDown}
onpointerup={handlePointerUp}
ontouchstart={handlePointerDown}
ontouchend={handlePointerUp}
>
{#if isRecording}
<span class="text-sm">Recording… {duration.toFixed(1)}s</span>
{:else}
<span class="text-sm">Hold to talk</span>
{/if}
</button>
</div>