141 lines
4.1 KiB
Svelte
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>
|