Spaces:
Sleeping
Sleeping
File size: 4,692 Bytes
07ed12b 7a30f51 07ed12b dd96d2f 07ed12b dd96d2f 07ed12b 5c0862e 07ed12b 5c0862e dd96d2f 07ed12b dd96d2f 07ed12b 68097bf 07ed12b dd96d2f 07ed12b dd96d2f 07ed12b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | <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>
|