Commit
Redesign push-to-talk as a circular mic button
commit
f45afe7
1 changed file with +24 and −6
modified client/src/components/PushToTalkButton.tsx +24 −6
| @@ -4,14 +4,16 @@import { createPushToTalkRecorder } from '../audio' | ||
| 4 | 4 | interface PushToTalkButtonProps { |
| 5 | 5 | disabled?: boolean |
| 6 | 6 | onRecorded: (blob: Blob) => void |
| 7 | + onRecordingChange?: (recording: boolean) => void | |
| 8 | + onLevel?: (level: number) => void | |
| 7 | 9 | } |
| 8 | 10 | |
| 9 | 11 | function isTypingTarget(target: EventTarget | null): boolean { |
| 10 | 12 | if (!(target instanceof HTMLElement)) return false |
| 11 | 13 | return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable |
| 12 | 14 | } |
| 13 | 15 | |
| 14 | -export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps) { | |
| 16 | +export function PushToTalkButton({ disabled, onRecorded, onRecordingChange, onLevel }: PushToTalkButtonProps) { | |
| 15 | 17 | const [recording, setRecording] = useState(false) |
| 16 | 18 | const [error, setError] = useState<string | null>(null) |
| 17 | 19 | const recorderRef = useRef(createPushToTalkRecorder()) |
| @@ -22,25 +24,27 @@export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps | ||
| 22 | 24 | activeRef.current = true |
| 23 | 25 | setError(null) |
| 24 | 26 | try { |
| 25 | - await recorderRef.current.start() | |
| 27 | + await recorderRef.current.start(onLevel) | |
| 26 | 28 | setRecording(true) |
| 29 | + onRecordingChange?.(true) | |
| 27 | 30 | } catch (err) { |
| 28 | 31 | activeRef.current = false |
| 29 | 32 | setError(err instanceof Error ? err.message : 'could not access the microphone') |
| 30 | 33 | } |
| 31 | - }, [disabled]) | |
| 34 | + }, [disabled, onLevel, onRecordingChange]) | |
| 32 | 35 | |
| 33 | 36 | const stopRecording = useCallback(async () => { |
| 34 | 37 | if (!activeRef.current) return |
| 35 | 38 | activeRef.current = false |
| 36 | 39 | setRecording(false) |
| 40 | + onRecordingChange?.(false) | |
| 37 | 41 | try { |
| 38 | 42 | const blob = await recorderRef.current.stop() |
| 39 | 43 | onRecorded(blob) |
| 40 | 44 | } catch (err) { |
| 41 | 45 | setError(err instanceof Error ? err.message : 'recording failed') |
| 42 | 46 | } |
| 43 | - }, [onRecorded]) | |
| 47 | + }, [onRecorded, onRecordingChange]) | |
| 44 | 48 | |
| 45 | 49 | useEffect(() => { |
| 46 | 50 | function handleKeyDown(event: KeyboardEvent) { |
| @@ -67,16 +71,30 @@export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps | ||
| 67 | 71 | <div className="push-to-talk"> |
| 68 | 72 | <button |
| 69 | 73 | type="button" |
| 70 | - className={`record-button ${recording ? 'recording' : ''}`} | |
| 74 | + className={`mic-button ${recording ? 'recording' : ''}`} | |
| 71 | 75 | disabled={disabled} |
| 76 | + aria-label={recording ? 'Release to send' : 'Hold to talk'} | |
| 72 | 77 | onMouseDown={() => void startRecording()} |
| 73 | 78 | onMouseUp={() => void stopRecording()} |
| 74 | 79 | onMouseLeave={() => { |
| 75 | 80 | if (recording) void stopRecording() |
| 76 | 81 | }} |
| 82 | + onTouchStart={(e) => { | |
| 83 | + e.preventDefault() | |
| 84 | + void startRecording() | |
| 85 | + }} | |
| 86 | + onTouchEnd={(e) => { | |
| 87 | + e.preventDefault() | |
| 88 | + void stopRecording() | |
| 89 | + }} | |
| 77 | 90 | > |
| 78 | - {recording ? 'Recording… release to send' : 'Hold to talk (or press space)'} | |
| 91 | + <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"> | |
| 92 | + <rect x="9" y="3" width="6" height="11" rx="3" /> | |
| 93 | + <path d="M5 11a7 7 0 0 0 14 0" /> | |
| 94 | + <path d="M12 18v3" /> | |
| 95 | + </svg> | |
| 79 | 96 | </button> |
| 97 | + <p className="mic-hint">{recording ? 'release to send' : 'hold to talk · or press space'}</p> | |
| 80 | 98 | {error && <p className="error">{error}</p>} |
| 81 | 99 | </div> |
| 82 | 100 | ) |