feat: Add push-to-talk keyboard shortcut (Ctrl+S) to FloatingVoice

- Hold Ctrl+S for 500ms to start recording
- Release to stop recording and send to terminal
- Shows PTT indicator when using keyboard shortcut
This commit is contained in:
2026-02-13 21:28:44 -06:00
parent 2c0ece71b2
commit 306aade623

View File

@@ -38,11 +38,16 @@ const WS_URL = `ws://${window.location.hostname}:4103`
let socket: WebSocket | null = null
const connected = ref(false)
// Push-to-talk state (Ctrl+S)
let keyDownTime = 0
let holdTimeout: number | null = null
const isPushToTalk = ref(false)
const displayText = computed(() => {
if (interimTranscript.value) {
return transcript.value + ' ' + interimTranscript.value
}
return transcript.value || 'Presiona el micrófono para comenzar...'
return transcript.value || 'Presiona el micrófono o mantén Ctrl+S...'
})
const containerStyle = computed(() => {
@@ -146,12 +151,13 @@ function clearTranscript() {
}
function connectSocket() {
console.log('[Voice] connectSocket called, current socket:', socket?.readyState)
if (socket && socket.readyState === WebSocket.OPEN) return
socket = new WebSocket(WS_URL)
socket.onopen = () => {
console.log('[Voice] WebSocket connected')
console.log('[Voice] WebSocket connected, readyState:', socket?.readyState)
connected.value = true
}
@@ -246,16 +252,115 @@ function stopDrag() {
document.removeEventListener('mouseup', stopDrag)
}
// Keyboard shortcut handlers (Ctrl+S)
function handleKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault()
// Ignore if already holding
if (keyDownTime > 0) return
keyDownTime = Date.now()
// Open panel and connect if not open
if (!isOpen.value) {
isOpen.value = true
}
// Start recording after 500ms hold
holdTimeout = window.setTimeout(() => {
if (keyDownTime > 0 && !isRecording.value) {
isPushToTalk.value = true
startRecording()
}
}, 500)
}
}
function handleKeyUp(e: KeyboardEvent) {
// Only react to 's' key release, ignore Control
if (e.key.toLowerCase() === 's' && keyDownTime > 0) {
console.log('[Voice] Key S released, isPushToTalk:', isPushToTalk.value, 'isRecording:', isRecording.value)
if (holdTimeout) {
clearTimeout(holdTimeout)
holdTimeout = null
}
// If was push-to-talk recording, stop and send after 500ms
if (isPushToTalk.value && isRecording.value) {
console.log('[Voice] Stopping recording, will send in 500ms')
stopRecording()
setTimeout(() => {
console.log('[Voice] Sending transcript:', transcript.value.trim())
console.log('[Voice] Socket state:', socket?.readyState)
if (transcript.value.trim()) {
sendTranscriptAndClose()
} else {
// No transcript, just close
isPushToTalk.value = false
close()
}
}, 500)
}
keyDownTime = 0
}
}
// Send and close for push-to-talk mode
function sendTranscriptAndClose() {
console.log('[Voice] sendTranscriptAndClose called')
const text = transcript.value.trim()
if (!text) {
console.log('[Voice] No text, closing')
isPushToTalk.value = false
close()
return
}
console.log('[Voice] Text to send:', text)
console.log('[Voice] Socket:', socket, 'readyState:', socket?.readyState)
if (!socket || socket.readyState !== WebSocket.OPEN) {
canvasStore.showNotification('Not connected to terminal', 'error')
isPushToTalk.value = false
return
}
// Send text character by character then Enter
const chars = (text + '\r').split('')
let i = 0
const typeChar = () => {
if (i < chars.length && socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: chars[i] }))
i++
setTimeout(typeChar, 15)
} else if (i >= chars.length) {
canvasStore.showNotification('Voice message sent', 'success')
clearTranscript()
isPushToTalk.value = false
close()
}
}
typeChar()
}
onMounted(() => {
recognition = initRecognition()
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
})
onBeforeUnmount(() => {
stopRecording()
recognition = null
disconnectSocket()
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
if (holdTimeout) clearTimeout(holdTimeout)
})
// Connect when panel opens, disconnect when closes
@@ -298,7 +403,7 @@ defineExpose({
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<span>Voice</span>
<i class="dot" :class="{ recording: isRecording }"></i>
<i class="dot" :class="{ recording: isRecording, ptt: isPushToTalk }"></i>
</div>
<div class="window-controls">
<button class="x" @click="close" title="Close">
@@ -316,7 +421,7 @@ defineExpose({
<span class="final">{{ transcript }}</span>
<span class="interim">{{ interimTranscript }}</span>
<span v-if="!transcript && !interimTranscript" class="placeholder">
Presiona el micrófono para comenzar...
Presiona el micrófono o mantén Ctrl+S...
</span>
</div>
@@ -431,6 +536,11 @@ defineExpose({
animation: pulse 0.8s infinite;
}
.dot.ptt {
background: #f90;
box-shadow: 0 0 6px #f90;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }