Update README.md with setup instructions, project stack details, chat functionality, and PocketBase integration information.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user