ChatCraft / frontend /src /lib /components /VoiceButton.svelte
gabraken's picture
besst
7a30f51
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { voiceStatus } from '$lib/stores/game';
import { startRecording, blobToBase64 } from '$lib/voice';
const dispatch = createEventDispatcher<{
send: { audio_b64: string; mime_type: string };
}>();
let stopFn: (() => void) | null = null;
async function onPressStart(e: MouseEvent | TouchEvent) {
e.preventDefault();
if ($voiceStatus !== 'idle') return;
try {
const { stop, result } = await startRecording();
stopFn = stop;
voiceStatus.set('recording');
result.then(async ({ blob, mimeType }) => {
const audio_b64 = await blobToBase64(blob);
dispatch('send', { audio_b64, mime_type: mimeType });
});
} catch {
voiceStatus.set('idle');
console.error('Microphone not available');
}
}
function onPressEnd(e: MouseEvent | TouchEvent) {
e.preventDefault();
if ($voiceStatus !== 'recording') return;
stopFn?.();
stopFn = null;
voiceStatus.set('processing');
// On mobile, prevent the browser from refocusing the last active input
// which would reopen the virtual keyboard
if (e instanceof TouchEvent) {
(document.activeElement as HTMLElement | null)?.blur();
}
}
async function onKeyDown(e: KeyboardEvent) {
if (e.code !== 'Space' || !e.ctrlKey) return;
e.preventDefault();
if ($voiceStatus !== 'idle') return;
try {
const { stop, result } = await startRecording();
stopFn = stop;
voiceStatus.set('recording');
result.then(async ({ blob, mimeType }) => {
const audio_b64 = await blobToBase64(blob);
dispatch('send', { audio_b64, mime_type: mimeType });
});
} catch {
voiceStatus.set('idle');
console.error('Microphone not available');
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.code !== 'Space' || !e.ctrlKey) return;
e.preventDefault();
if ($voiceStatus !== 'recording') return;
stopFn?.();
stopFn = null;
voiceStatus.set('processing');
}
</script>
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} />
<button
class="voice-btn"
class:recording={$voiceStatus === 'recording'}
class:processing={$voiceStatus === 'processing'}
disabled={$voiceStatus === 'processing'}
on:mousedown={onPressStart}
on:mouseup={onPressEnd}
on:mouseleave={onPressEnd}
on:touchstart|nonpassive={onPressStart}
on:touchend|nonpassive={onPressEnd}
on:touchcancel={onPressEnd}
aria-label="Hold to speak"
>
{#if $voiceStatus === 'recording'}
<span class="pulse-ring"></span>
<span class="pulse-ring ring2"></span>
<span class="mic-icon">🔴</span>
{:else if $voiceStatus === 'processing'}
<span class="spinner"></span>
{:else}
<span class="mic-icon">🎙️</span>
{/if}
<span class="btn-label">
{#if $voiceStatus === 'recording'}Listening…
{:else if $voiceStatus === 'processing'}Processing…
{:else}Hold / Ctrl+Space
{/if}
</span>
</button>
<style>
.voice-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--surface2);
border: 2px solid var(--border);
color: var(--text);
transition: all 0.15s;
flex-shrink: 0;
overflow: visible;
touch-action: none;
-webkit-touch-callout: none;
user-select: none;
-webkit-user-select: none;
}
.voice-btn:not(:disabled):active,
.voice-btn.recording {
transform: scale(0.96);
border-color: var(--danger);
background: rgba(248, 81, 73, 0.15);
}
.voice-btn.processing {
border-color: var(--player);
opacity: 0.7;
}
.mic-icon {
font-size: 1.4rem;
line-height: 1;
margin-top: 10px;
}
.btn-label {
position: absolute;
bottom: -22px;
font-size: 0.65rem;
color: var(--text-muted);
white-space: nowrap;
pointer-events: none;
}
/* Pulse rings for recording */
.pulse-ring {
position: absolute;
inset: -8px;
border-radius: 50%;
border: 2px solid var(--danger);
opacity: 0;
animation: pulse-out 1.2s ease-out infinite;
pointer-events: none;
}
.ring2 { animation-delay: 0.4s; }
@keyframes pulse-out {
0% { inset: -4px; opacity: 0.7; }
100% { inset: -20px; opacity: 0; }
}
/* Processing spinner */
.spinner {
position: absolute;
inset: 6px;
border-radius: 50%;
border: 3px solid var(--border);
border-top-color: var(--player);
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>