VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble with voice display, AgentBadge terminal switcher, ChatContainer voice integration, FloatingTranscriptDebug ocean life enhancements, and terminal registry support. Remove traefik config.
192 lines
5.2 KiB
Vue
192 lines
5.2 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onBeforeUnmount } from 'vue'
|
|
|
|
const props = defineProps<{
|
|
isRecording: boolean
|
|
voiceMode: 'web' | 'whisper'
|
|
whisperStatus: 'offline' | 'loading' | 'ready'
|
|
disabled?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
start: []
|
|
stop: []
|
|
}>()
|
|
|
|
// ── Interaction state machine ──
|
|
// idle → pointerdown → wait 250ms
|
|
// if pointerup < 250ms → "click" → locked (recording until next press+release)
|
|
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
|
|
// locked → pointerdown → stopping
|
|
// stopping → pointerup → stop recording → idle
|
|
// ptt → pointerup → wait 500ms trail → stop recording → idle
|
|
|
|
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
|
|
const mode = ref<Mode>('idle')
|
|
|
|
const HOLD_THRESHOLD = 250
|
|
const TRAIL_BUFFER = 500
|
|
|
|
let holdTimer: number | null = null
|
|
let trailTimer: number | null = null
|
|
|
|
function clearTimers() {
|
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
|
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (props.disabled) return
|
|
e.preventDefault()
|
|
// Capture pointer so we get pointerup even if cursor leaves button
|
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
|
|
if (mode.value === 'idle') {
|
|
// Start hold detection
|
|
holdTimer = window.setTimeout(() => {
|
|
holdTimer = null
|
|
// Still holding after 250ms → PTT mode
|
|
mode.value = 'ptt'
|
|
emit('start')
|
|
}, HOLD_THRESHOLD)
|
|
} else if (mode.value === 'locked') {
|
|
// Second press while locked → prepare to stop on release
|
|
mode.value = 'stopping'
|
|
}
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
if (props.disabled) return
|
|
e.preventDefault()
|
|
|
|
if (mode.value === 'idle' && holdTimer) {
|
|
// Quick click (< 250ms) → lock mode
|
|
clearTimers()
|
|
mode.value = 'locked'
|
|
emit('start')
|
|
} else if (mode.value === 'ptt') {
|
|
// Release from PTT → 500ms trailing buffer then stop
|
|
clearTimers()
|
|
trailTimer = window.setTimeout(() => {
|
|
trailTimer = null
|
|
mode.value = 'idle'
|
|
emit('stop')
|
|
}, TRAIL_BUFFER)
|
|
} else if (mode.value === 'stopping') {
|
|
// Release from second press → stop immediately
|
|
clearTimers()
|
|
mode.value = 'idle'
|
|
emit('stop')
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTimers()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<button
|
|
class="mic-btn"
|
|
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
|
|
:disabled="disabled"
|
|
@pointerdown="onPointerDown"
|
|
@pointerup="onPointerUp"
|
|
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
|
|
>
|
|
<!-- Mic icon -->
|
|
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
</svg>
|
|
<!-- Stop icon (square) when recording -->
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
|
</svg>
|
|
<!-- Mode badge -->
|
|
<span class="mode-badge" :class="voiceMode">
|
|
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
|
|
</span>
|
|
</button>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mic-btn {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
background: var(--bg-hover, rgba(255,255,255,0.06));
|
|
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
|
border-radius: 6px;
|
|
color: var(--text-muted, #888);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: all 0.15s;
|
|
touch-action: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.mic-btn:hover:not(:disabled) {
|
|
background: var(--bg-hover, rgba(255,255,255,0.1));
|
|
color: var(--text-primary, #ccc);
|
|
border-color: var(--text-muted, rgba(255,255,255,0.15));
|
|
}
|
|
|
|
.mic-btn.recording {
|
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
|
border-color: rgba(239, 68, 68, 0.5);
|
|
color: white;
|
|
animation: rec-pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.mic-btn.recording.ptt {
|
|
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
|
border-color: rgba(249, 115, 22, 0.5);
|
|
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
|
|
}
|
|
|
|
.mic-btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.mode-badge {
|
|
position: absolute;
|
|
bottom: -4px;
|
|
right: -4px;
|
|
font-size: 7px;
|
|
font-weight: 700;
|
|
font-family: 'Courier New', monospace;
|
|
padding: 1px 3px;
|
|
border-radius: 3px;
|
|
line-height: 1;
|
|
letter-spacing: 0.3px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.mode-badge.whisper {
|
|
background: rgba(16, 185, 129, 0.9);
|
|
color: white;
|
|
}
|
|
|
|
.mode-badge.web {
|
|
background: rgba(59, 130, 246, 0.9);
|
|
color: white;
|
|
}
|
|
|
|
@keyframes rec-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
|
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
|
|
}
|
|
|
|
@keyframes rec-pulse-ptt {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
|
|
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
|
|
}
|
|
</style>
|