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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user