bloby-bot 0.18.16 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -258,56 +258,74 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
|
|
|
258
258
|
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
+
// ── Device detection ──
|
|
262
|
+
const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
263
|
+
|
|
264
|
+
// ── Start recording helper (shared by desktop click & mobile hold) ──
|
|
265
|
+
const beginRecording = useCallback(async () => {
|
|
266
|
+
if (!voiceEnabled) return;
|
|
267
|
+
try {
|
|
268
|
+
if (whisperEnabled) {
|
|
269
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
270
|
+
if (!isMobile && !pointerIsDown.current) {
|
|
271
|
+
// Desktop: pointer already released is fine (click), keep going
|
|
272
|
+
} else if (isMobile && !pointerIsDown.current) {
|
|
273
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
streamRef.current = stream;
|
|
277
|
+
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
278
|
+
const recorder = new MediaRecorder(stream, { mimeType });
|
|
279
|
+
audioChunksRef.current = [];
|
|
280
|
+
recorder.ondataavailable = (ev) => {
|
|
281
|
+
if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
|
|
282
|
+
};
|
|
283
|
+
mediaRecorderRef.current = recorder;
|
|
284
|
+
recorder.start();
|
|
285
|
+
} else {
|
|
286
|
+
startSpeech(!isMobile); // desktop: continuous=true, mobile: continuous=false
|
|
287
|
+
if (isMobile && !pointerIsDown.current) {
|
|
288
|
+
abortSpeech();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
isHolding.current = true;
|
|
294
|
+
setIsRecording(true);
|
|
295
|
+
setRecordingTime(0);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error('[InputBar] recording setup failed:', err);
|
|
298
|
+
}
|
|
299
|
+
}, [voiceEnabled, whisperEnabled, startSpeech, abortSpeech]);
|
|
300
|
+
|
|
261
301
|
// ── Mic pointer handlers ──
|
|
262
302
|
const handleMicDown = useCallback((e: RPointerEvent) => {
|
|
263
303
|
e.preventDefault();
|
|
304
|
+
|
|
305
|
+
if (!isMobile) {
|
|
306
|
+
// Desktop: click-to-toggle
|
|
307
|
+
if (isRecording) {
|
|
308
|
+
stopRecording(false);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
pointerIsDown.current = true;
|
|
312
|
+
beginRecording();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Mobile: press-and-hold
|
|
264
317
|
pointerIsDown.current = true;
|
|
265
318
|
startXRef.current = e.clientX;
|
|
266
319
|
dragRef.current = 0;
|
|
267
320
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
268
321
|
|
|
269
|
-
holdTimerRef.current = setTimeout(
|
|
270
|
-
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
try {
|
|
274
|
-
if (whisperEnabled) {
|
|
275
|
-
// Whisper path: need getUserMedia + MediaRecorder for audio capture
|
|
276
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
277
|
-
// If user released while permission dialog was showing, clean up and bail
|
|
278
|
-
if (!pointerIsDown.current) {
|
|
279
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
streamRef.current = stream;
|
|
283
|
-
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
284
|
-
const recorder = new MediaRecorder(stream, { mimeType });
|
|
285
|
-
audioChunksRef.current = [];
|
|
286
|
-
recorder.ondataavailable = (ev) => {
|
|
287
|
-
if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
|
|
288
|
-
};
|
|
289
|
-
mediaRecorderRef.current = recorder;
|
|
290
|
-
recorder.start();
|
|
291
|
-
} else {
|
|
292
|
-
// Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
|
|
293
|
-
startSpeech();
|
|
294
|
-
if (!pointerIsDown.current) {
|
|
295
|
-
abortSpeech();
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
isHolding.current = true;
|
|
301
|
-
setIsRecording(true);
|
|
302
|
-
setRecordingTime(0);
|
|
303
|
-
} catch (err) {
|
|
304
|
-
console.error('[InputBar] recording setup failed:', err);
|
|
305
|
-
}
|
|
322
|
+
holdTimerRef.current = setTimeout(() => {
|
|
323
|
+
beginRecording();
|
|
306
324
|
}, 200);
|
|
307
|
-
}, [
|
|
325
|
+
}, [isMobile, isRecording, voiceEnabled, beginRecording, stopRecording]);
|
|
308
326
|
|
|
309
327
|
const handleMicMove = useCallback((e: RPointerEvent) => {
|
|
310
|
-
if (!isHolding.current) return;
|
|
328
|
+
if (!isMobile || !isHolding.current) return;
|
|
311
329
|
const dx = Math.min(0, e.clientX - startXRef.current);
|
|
312
330
|
dragRef.current = dx;
|
|
313
331
|
if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
|
|
@@ -319,16 +337,17 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
|
|
|
319
337
|
stopRecording(true);
|
|
320
338
|
}
|
|
321
339
|
}
|
|
322
|
-
}, [stopRecording]);
|
|
340
|
+
}, [isMobile, stopRecording]);
|
|
323
341
|
|
|
324
342
|
const handleMicUp = useCallback(() => {
|
|
325
343
|
pointerIsDown.current = false;
|
|
326
344
|
if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
345
|
+
// Desktop: don't stop on release (toggle mode)
|
|
346
|
+
if (!isMobile) return;
|
|
347
|
+
// Mobile: stop on release
|
|
348
|
+
if (!isHolding.current) return;
|
|
330
349
|
stopRecording(false);
|
|
331
|
-
}, [stopRecording]);
|
|
350
|
+
}, [isMobile, stopRecording]);
|
|
332
351
|
|
|
333
352
|
const handleMicCancel = useCallback(() => {
|
|
334
353
|
pointerIsDown.current = false;
|
|
@@ -477,44 +496,70 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
|
|
|
477
496
|
</span>
|
|
478
497
|
</div>
|
|
479
498
|
|
|
480
|
-
{/* Pill:
|
|
499
|
+
{/* Pill: content depends on desktop vs mobile */}
|
|
481
500
|
<div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
className="
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
501
|
+
{isMobile ? (
|
|
502
|
+
<>
|
|
503
|
+
<div
|
|
504
|
+
ref={trashRef}
|
|
505
|
+
className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
|
|
506
|
+
>
|
|
507
|
+
<Trash2 className="h-4 w-4" />
|
|
508
|
+
</div>
|
|
509
|
+
<div className="flex-1 flex justify-center min-w-0">
|
|
510
|
+
<motion.span
|
|
511
|
+
className="text-[13px] whitespace-nowrap select-none font-medium"
|
|
512
|
+
style={{
|
|
513
|
+
backgroundImage: 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
|
|
514
|
+
backgroundSize: '200% 100%',
|
|
515
|
+
WebkitBackgroundClip: 'text',
|
|
516
|
+
WebkitTextFillColor: 'transparent',
|
|
517
|
+
backgroundClip: 'text',
|
|
518
|
+
}}
|
|
519
|
+
animate={{ backgroundPosition: ['200% center', '-200% center'] }}
|
|
520
|
+
transition={{ duration: 2.5, repeat: Infinity, ease: 'linear' }}
|
|
521
|
+
>
|
|
522
|
+
◄◄ slide to cancel
|
|
523
|
+
</motion.span>
|
|
524
|
+
</div>
|
|
525
|
+
{/* Draggable mic (DOM transform for 60fps) */}
|
|
526
|
+
<div
|
|
527
|
+
ref={micRef}
|
|
528
|
+
className="shrink-0 touch-none select-none will-change-transform"
|
|
529
|
+
style={{ transition: 'none' }}
|
|
530
|
+
onPointerDown={handleMicDown}
|
|
531
|
+
onPointerMove={handleMicMove}
|
|
532
|
+
onPointerUp={handleMicUp}
|
|
533
|
+
onPointerCancel={handleMicCancel}
|
|
534
|
+
>
|
|
535
|
+
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
|
|
536
|
+
<Mic className="h-5 w-5" />
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
</>
|
|
540
|
+
) : (
|
|
541
|
+
<>
|
|
542
|
+
<button
|
|
543
|
+
onClick={() => stopRecording(true)}
|
|
544
|
+
className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
|
|
545
|
+
>
|
|
546
|
+
<Trash2 className="h-4 w-4" />
|
|
547
|
+
</button>
|
|
548
|
+
<div className="flex-1 flex justify-center min-w-0">
|
|
549
|
+
<span className="text-[13px] whitespace-nowrap select-none font-medium text-muted-foreground">
|
|
550
|
+
Click mic to stop
|
|
551
|
+
</span>
|
|
552
|
+
</div>
|
|
553
|
+
<button
|
|
554
|
+
onClick={() => stopRecording(false)}
|
|
555
|
+
className="shrink-0"
|
|
556
|
+
>
|
|
557
|
+
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors">
|
|
558
|
+
<Square className="h-4 w-4" />
|
|
559
|
+
</div>
|
|
560
|
+
</button>
|
|
561
|
+
</>
|
|
562
|
+
)}
|
|
518
563
|
</div>
|
|
519
564
|
</div>
|
|
520
565
|
</motion.div>
|
|
@@ -38,7 +38,7 @@ export function useSpeechRecognition() {
|
|
|
38
38
|
|
|
39
39
|
const isSupported = useMemo(() => isWebSpeechSupported, []);
|
|
40
40
|
|
|
41
|
-
const start = useCallback(() => {
|
|
41
|
+
const start = useCallback((continuous = false) => {
|
|
42
42
|
const Ctor = getSpeechRecognitionCtor();
|
|
43
43
|
if (!Ctor) {
|
|
44
44
|
return;
|
|
@@ -50,8 +50,8 @@ export function useSpeechRecognition() {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const recognition = new Ctor();
|
|
53
|
-
recognition.continuous =
|
|
54
|
-
recognition.interimResults = true;
|
|
53
|
+
recognition.continuous = continuous; // desktop: true (click-to-toggle), mobile: false (hold-to-talk)
|
|
54
|
+
recognition.interimResults = true; // get progressive updates while speaking
|
|
55
55
|
recognition.lang = navigator.language || 'en-US';
|
|
56
56
|
|
|
57
57
|
transcriptRef.current = '';
|