import * as React from "react"; import type { ButtonProps, ButtonSizeType } from "@sparkle/components/Button"; import { Button } from "@sparkle/components/Button"; import { MicIcon, SquareIcon } from "@sparkle/icons/app"; import { cn } from "@sparkle/lib/utils"; const DEFAULT_PRESS_DELAY_MS = 150; const VOICE_LEVEL_BASE_HEIGHTS = [ 22, 33, 18, 64, 98, 56, 6, 34, 76, 46, 12, 22, ]; export type VoicePickerStatus = | "idle" | "authorizing_microphone" | "recording" | "transcribing"; type VoicePickerInteractionMode = "hold" | "click"; export interface VoicePickerProps { status: VoicePickerStatus; level: number; elapsedSeconds: number; onRecordStart: () => void | Promise; onRecordStop: () => void | Promise; size?: Exclude; disabled?: boolean; showStopLabel?: boolean; pressDelayMs?: number; buttonProps?: Omit< ButtonProps, "icon" | "label" | "variant" | "isLoading" | "disabled" | "size" >; } export function VoicePicker({ status, level, elapsedSeconds, onRecordStart, onRecordStop, size = "xs", disabled = false, showStopLabel = false, pressDelayMs = DEFAULT_PRESS_DELAY_MS, buttonProps, }: VoicePickerProps): React.ReactElement { const [interactionMode, setInteractionMode] = React.useState("hold"); const pressStartRef = React.useRef(null); const pressTimeoutRef = React.useRef(null); const isRecording = status === "recording"; const isTranscribing = status === "transcribing"; const isLoading = status === "transcribing" || status === "authorizing_microphone"; const shouldShowStop = isRecording && interactionMode === "click"; function clearPressTimeout(): void { if (pressTimeoutRef.current !== null) { window.clearTimeout(pressTimeoutRef.current); pressTimeoutRef.current = null; } } function stopEvent(event: React.SyntheticEvent): void { event.preventDefault(); event.stopPropagation(); } async function handlePointerDown( event: React.PointerEvent ): Promise { buttonProps?.onPointerDown?.(event); if (event.defaultPrevented) { return; } stopEvent(event); if (disabled) { return; } pressStartRef.current = Date.now(); clearPressTimeout(); pressTimeoutRef.current = window.setTimeout(async () => { if (pressStartRef.current !== null) { setInteractionMode("hold"); if (status === "idle") { await onRecordStart(); } } }, pressDelayMs); } async function handlePointerUp( event: React.PointerEvent ): Promise { buttonProps?.onPointerUp?.(event); if (event.defaultPrevented) { return; } stopEvent(event); if (disabled) { return; } const start = pressStartRef.current; clearPressTimeout(); pressStartRef.current = null; const duration = start === null ? Number.POSITIVE_INFINITY : Date.now() - start; if (duration < pressDelayMs) { setInteractionMode("click"); if (status === "idle") { await onRecordStart(); return; } await onRecordStop(); return; } if (status === "recording") { await onRecordStop(); } } async function handlePointerLeave( event: React.PointerEvent ): Promise { buttonProps?.onPointerLeave?.(event); if (event.defaultPrevented) { return; } stopEvent(event); if (disabled || interactionMode !== "hold") { return; } if (status === "recording") { await onRecordStop(); } } async function handleClick( event: React.MouseEvent ): Promise { buttonProps?.onClick?.(event); if (event.defaultPrevented) { return; } stopEvent(event); if (disabled || interactionMode !== "click") { return; } if (status === "recording") { await onRecordStop(); } } const icon = shouldShowStop ? SquareIcon : MicIcon; const variant = shouldShowStop ? "highlight" : "ghost-secondary"; const label = shouldShowStop && showStopLabel ? "Stop" : undefined; const tooltip = computeTooltip(interactionMode, isRecording, isTranscribing); return ( <>
{formatTime(elapsedSeconds)}