@@ -880,6 +993,60 @@ defineExpose({
border-radius: 2px;
overflow: hidden;
background: rgba(0,0,0,0.92);
+ position: relative;
+}
+
+/* Disconnected/Connecting overlay */
+.disconnect-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ cursor: pointer;
+}
+
+.disconnect-overlay.connecting {
+ cursor: wait;
+}
+
+.disconnect-msg {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ color: #fff;
+ text-align: center;
+}
+
+.disconnect-msg svg {
+ color: #ef4444;
+ opacity: 0.8;
+}
+
+.disconnect-msg span {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.disconnect-msg small {
+ font-size: 11px;
+ color: #888;
+}
+
+.spinner {
+ width: 24px;
+ height: 24px;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
}
.resize-handle {
@@ -913,6 +1080,13 @@ defineExpose({
}
.term :deep(.xterm-viewport) {
overflow-y: auto !important;
+ /* Enable touch scrolling on mobile */
+ -webkit-overflow-scrolling: touch;
+ touch-action: pan-y;
+}
+.term :deep(.xterm-screen) {
+ /* Allow touch events to pass through for scrolling */
+ touch-action: pan-y;
}
.term :deep(.xterm-viewport::-webkit-scrollbar) {
width: 8px;
@@ -981,6 +1155,9 @@ defineExpose({
.aero-win.mobile .content {
flex: 1;
min-height: 100px;
+ /* Allow touch scrolling in terminal area */
+ touch-action: pan-y;
+ overflow: hidden;
}
/* Mobile animations */
@@ -1003,14 +1180,14 @@ defineExpose({
}
.vk {
- min-width: 40px;
- height: 32px;
- padding: 0 8px;
+ min-width: 48px;
+ height: 40px;
+ padding: 0 10px;
background: linear-gradient(180deg, #444 0%, #333 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
- border-radius: 5px;
+ border-radius: 6px;
color: #fff;
- font-size: 11px;
+ font-size: 13px;
font-weight: 500;
font-family: system-ui, sans-serif;
cursor: pointer;
@@ -1033,7 +1210,13 @@ defineExpose({
border-color: rgba(100, 150, 255, 0.3);
}
-.vk-arrows {
+.vk.refresh {
+ background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%);
+ border-color: rgba(245, 158, 11, 0.3);
+ font-size: 18px;
+}
+
+.vk-scroll {
display: flex;
flex-direction: column;
align-items: center;
@@ -1041,16 +1224,39 @@ defineExpose({
margin-left: auto;
}
+.vk.scroll {
+ min-width: 44px;
+ width: 44px;
+ height: 36px;
+ padding: 0;
+ font-size: 16px;
+ background: linear-gradient(180deg, #065f46 0%, #047857 100%);
+ border-color: rgba(16, 185, 129, 0.3);
+}
+
+.vk.scroll.end {
+ background: linear-gradient(180deg, #7c3aed 0%, #6d28d9 100%);
+ border-color: rgba(139, 92, 246, 0.3);
+ font-size: 18px;
+}
+
+.vk-arrows {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+}
+
.vk-row {
display: flex;
gap: 2px;
}
.vk.arrow {
- min-width: 32px;
- width: 32px;
- height: 26px;
+ min-width: 38px;
+ width: 38px;
+ height: 32px;
padding: 0;
- font-size: 10px;
+ font-size: 12px;
}
diff --git a/frontend/src/components/FloatingVoice.vue b/frontend/src/components/FloatingVoice.vue
index be4d940..1ce061a 100644
--- a/frontend/src/components/FloatingVoice.vue
+++ b/frontend/src/components/FloatingVoice.vue
@@ -46,6 +46,7 @@ const isRecording = ref(false)
const transcript = ref('')
const interimTranscript = ref('')
const error = ref('')
+let lastProcessedResult = '' // Track last result to avoid duplicates on Android
// Typing animation state
const animatedTranscript = ref('')
@@ -93,6 +94,7 @@ const showMicSelector = ref(false)
// ============ MOBILE DETECTION & AUDIO FORMAT ============
const isMobile = ref(false)
+const isAndroid = ref(false)
const supportedMimeType = ref('audio/webm;codecs=opus')
const sheetHeight = ref(45) // percentage of viewport for mobile
const isDraggingSheet = ref(false)
@@ -101,7 +103,9 @@ const keyboardHeight = ref(0)
const snapPoints = [25, 45, 70] // collapsed, default, expanded
function checkMobile() {
- isMobile.value = window.innerWidth <= 640 || /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
+ const ua = navigator.userAgent
+ isMobile.value = window.innerWidth <= 640 || /iPhone|iPad|iPod|Android/i.test(ua)
+ isAndroid.value = /Android/i.test(ua)
}
// Virtual keyboard detection
@@ -338,10 +342,16 @@ function initRecognition() {
}
const rec = new SpeechRecognition()
- rec.continuous = true
+ // Android Chrome doesn't support continuous mode properly - it stops after first result
+ // Using non-continuous mode on Android prevents constant restarts and audio loss
+ rec.continuous = !isAndroid.value
rec.interimResults = true
rec.lang = 'es-419' // Latin American Spanish (better for accents)
+ if (isAndroid.value) {
+ console.log('[Voice] Android detected - using non-continuous mode for stability')
+ }
+
rec.onresult = (event: SpeechRecognitionEvent) => {
let interim = ''
let final = ''
@@ -358,7 +368,19 @@ function initRecognition() {
}
if (final) {
- transcript.value += final
+ // Android: Check for duplicates (same text being processed again)
+ const trimmedFinal = final.trim()
+ if (isAndroid.value && lastProcessedResult && trimmedFinal.startsWith(lastProcessedResult.trim())) {
+ // Only add the new part
+ const newPart = trimmedFinal.slice(lastProcessedResult.trim().length).trim()
+ if (newPart) {
+ transcript.value += newPart + ' '
+ lastProcessedResult = trimmedFinal
+ }
+ } else {
+ transcript.value += final
+ lastProcessedResult = trimmedFinal
+ }
}
interimTranscript.value = interim
}
@@ -375,8 +397,15 @@ function initRecognition() {
rec.onend = () => {
if (isRecording.value && !useWhisper.value) {
- // Restart if still recording (browser stops after silence)
- rec.start()
+ if (isAndroid.value) {
+ // Android: Don't auto-restart - causes duplicate text
+ // User should use push-to-talk or tap mic button for each phrase
+ isRecording.value = false
+ console.log('[Voice] Android session ended - tap mic to continue')
+ } else {
+ // Desktop: Restart immediately (browser stops after silence)
+ rec.start()
+ }
}
}
@@ -501,6 +530,11 @@ async function pollWhisperStatus() {
}
function connectWhisperSocket() {
+ // Don't connect if Whisper isn't ready
+ if (!whisperReady.value) {
+ console.log('[Voice] Whisper not ready, skipping connection')
+ return
+ }
if (whisperSocket?.readyState === WebSocket.OPEN) return
console.log('[Voice] Connecting to Whisper server...')
@@ -581,6 +615,20 @@ function disconnectWhisperSocket() {
}
async function startWhisperRecording() {
+ // Ensure WebSocket is connected before recording
+ if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) {
+ console.warn('[Voice] Whisper socket not connected, attempting to connect...')
+ connectWhisperSocket()
+ // Wait a moment for connection
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) {
+ error.value = 'Whisper server not connected'
+ canvasStore.showNotification('Whisper not connected. Try toggling GPU mode.', 'error')
+ return
+ }
+ }
+
try {
// Mobile-optimized audio constraints
const audioConstraints: MediaTrackConstraints = {
@@ -616,9 +664,11 @@ async function startWhisperRecording() {
mediaRecorder.start(100) // Collect data every 100ms
isRecording.value = true
recordingStartTime = Date.now()
+ console.log(`[Voice] Whisper recording started, socket ready: ${whisperSocket?.readyState === WebSocket.OPEN}`)
// Send chunks periodically for progressive transcription
chunkInterval = window.setInterval(() => {
+ console.log(`[Voice] Chunk interval: ${audioChunks.length} chunks, socket: ${whisperSocket?.readyState}`)
if (audioChunks.length > 0 && whisperSocket?.readyState === WebSocket.OPEN) {
sendAudioChunk(false) // false = partial, don't clear
}
@@ -633,8 +683,10 @@ async function startWhisperRecording() {
function sendAudioChunk(isFinal: boolean) {
if (audioChunks.length === 0) return
- // Always send ALL accumulated audio (webm needs header from first chunk)
- const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
+ // Always send ALL accumulated audio (needs header from first chunk)
+ const mimeType = mediaRecorder?.mimeType || supportedMimeType.value || 'audio/webm'
+ const audioBlob = new Blob(audioChunks, { type: mimeType })
+ console.log(`[Voice] Sending chunk: ${audioChunks.length} chunks, ${audioBlob.size} bytes, ${mimeType}`)
const chunkCount = audioChunks.length
// Skip if audio is too small (< 5KB) - WebM header alone is ~1-2KB
@@ -719,6 +771,15 @@ function startRecording() {
try {
recognition.start()
isRecording.value = true
+
+ // Notify Android users about limitations
+ if (isAndroid.value && !isPushToTalk.value) {
+ canvasStore.showNotification(
+ 'Android: Tap mic again to continue recording',
+ 'info',
+ 3000
+ )
+ }
} catch (e) {
console.error('[Voice] Failed to start:', e)
}
@@ -743,6 +804,7 @@ function clearTranscript() {
interimTranscript.value = ''
animatedTranscript.value = ''
lastAnimatedLength = 0
+ lastProcessedResult = ''
if (typingTimeout) {
clearTimeout(typingTimeout)
typingTimeout = null
@@ -1025,8 +1087,13 @@ onMounted(async () => {
if (status?.starting) {
console.log('[Voice] Server is starting, resuming polling...')
pollWhisperStatus()
- } else if (useWhisper.value) {
+ } else if (useWhisper.value && whisperReady.value) {
+ // Only connect if both enabled AND actually running
connectWhisperSocket()
+ } else if (useWhisper.value && !whisperReady.value) {
+ // User had Whisper enabled but server isn't running - disable it
+ console.log('[Voice] Whisper was enabled but server not running, disabling')
+ useWhisper.value = false
}
})
@@ -1124,8 +1191,8 @@ defineExpose({
Voice
-
- {{ useWhisper ? 'GPU' : 'Web' }}
+
+ {{ useWhisper ? 'GPU' : (isAndroid ? 'Android' : 'Web') }}
@@ -1350,6 +1417,12 @@ defineExpose({
box-shadow: 0 0 4px rgba(16, 185, 129, 0.5);
}
+.mode-badge.android {
+ background: linear-gradient(135deg, #f59e0b, #d97706);
+ color: #fff;
+ box-shadow: 0 0 4px rgba(245, 158, 11, 0.5);
+}
+
.whisper-toggle {
width: 20px;
height: 18px;