Open History to see the last commit
1import type { VoiceInputState } from "@t3tools/client-runtime/voice-input";
2import { CheckIcon, MicIcon, XIcon } from "lucide-react";
3import { useEffect, useState, type PointerEvent, type ReactElement } from "react";
4
5import { VoiceInputErrorPill } from "./VoiceInputErrorPill";
6import { VoiceInputPill, VoiceWaveform } from "./VoiceInputPill";
7import { cn } from "~/lib/utils";
8import { Button } from "../ui/button";
9import { Spinner } from "../ui/spinner";
10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11
12type DownloadProgress = { downloaded: number; total: number } | null;
13
14const keepFocus = (event: PointerEvent) => event.preventDefault();
15
16/** One short status line per phase; also mirrored into the preview annotation editor. */
17export function resolveSpeechPresentation(
18 state: VoiceInputState,
19 progress: DownloadProgress,
20): { status: string | null; active: boolean } {
21 switch (state.phase) {
22 case "idle":
23 return { status: null, active: false };
24 case "error":
25 return { status: state.error ?? "Voice input failed", active: false };
26 case "preparing":
27 return {
28 status: progress
29 ? `Downloading model ${Math.round((progress.downloaded / Math.max(1, progress.total)) * 100)}%`
30 : "Starting…",
31 active: true,
32 };
33 case "recording":
34 return { status: "Recording", active: true };
35 case "transcribing":
36 return { status: "Transcribing…", active: true };
37 case "post-processing":
38 return { status: "Cleaning up…", active: true };
39 }
40}
41
42function formatElapsed(seconds: number) {
43 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
44}
45
46function RecordingStatus(props: { level: number; recordingLimitSeconds?: number | undefined }) {
47 const [elapsedSeconds, setElapsedSeconds] = useState(0);
48
49 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the timer displaying the native recording duration
50 useEffect(() => {
51 const startedAt = Date.now();
52 const timer = window.setInterval(
53 () => setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1_000)),
54 250,
55 );
56 return () => window.clearInterval(timer);
57 }, []);
58
59 return (
60 <>
61 <span aria-hidden className="size-2 shrink-0 rounded-full bg-destructive" />
62 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
63 {formatElapsed(elapsedSeconds)}
64 {props.recordingLimitSeconds !== undefined
65 ? ` / ${formatElapsed(props.recordingLimitSeconds)}`
66 : ""}
67 </span>
68 <div className="me-1.5 flex w-16 min-w-0 shrink grow">
69 <VoiceWaveform level={props.level} />
70 </div>
71 </>
72 );
73}
74
75function ShortcutTooltip(props: {
76 label: string;
77 shortcut?: string | null | undefined;
78 children: ReactElement;
79}) {
80 return (
81 <Tooltip>
82 <TooltipTrigger render={props.children} />
83 <TooltipPopup side="top">
84 {props.label}
85 {props.shortcut ? ` (${props.shortcut})` : ""}
86 </TooltipPopup>
87 </Tooltip>
88 );
89}
90
91export function ComposerSpeechButton(props: {
92 state: VoiceInputState;
93 shortcutLabel?: string | null;
94 disabled?: boolean;
95 /** Compact matches the `xs` buttons beside text-field editors. */
96 size?: "default" | "compact";
97 onStart(): void;
98}) {
99 if (props.state.phase !== "idle") return null;
100 const label = "Start voice input";
101
102 return (
103 <ShortcutTooltip label={label} shortcut={props.shortcutLabel}>
104 <Button
105 type="button"
106 size={props.size === "compact" ? "icon-xs" : "icon-sm"}
107 variant="ghost"
108 aria-label={label}
109 disabled={props.disabled}
110 onPointerDown={keepFocus}
111 onClick={() => {
112 if (!props.disabled) props.onStart();
113 }}
114 className="shrink-0"
115 >
116 <MicIcon />
117 </Button>
118 </ShortcutTooltip>
119 );
120}
121
122export function ComposerSpeechRecordingPill(props: {
123 state: VoiceInputState;
124 progress: DownloadProgress;
125 finishShortcutLabel?: string | null;
126 cancelShortcutLabel?: string | null;
127 level: number;
128 recordingLimitSeconds?: number | undefined;
129 /** Compact matches the `xs` buttons beside text-field editors. */
130 size?: "default" | "compact";
131 onStart(): void;
132 disabled?: boolean;
133 onStop(): void;
134 onCancel(): void;
135 onSkipPostProcessing(): void;
136}) {
137 const { phase } = props.state;
138 const compact = props.size === "compact";
139 if (phase === "error") {
140 return (
141 <VoiceInputErrorPill
142 size={props.size}
143 error={props.state.error}
144 errorAction={props.state.errorAction}
145 disabled={props.disabled ?? false}
146 cancelShortcutLabel={props.cancelShortcutLabel ?? null}
147 onRetry={props.onStart}
148 onDismiss={props.onCancel}
149 />
150 );
151 }
152 const presentation = resolveSpeechPresentation(props.state, props.progress);
153 if (!presentation.active) return null;
154 const recording = phase === "recording";
155 const trailing = recording || phase === "post-processing";
156
157 return (
158 <VoiceInputPill size={props.size}>
159 <ShortcutTooltip label="Cancel voice input" shortcut={props.cancelShortcutLabel}>
160 <Button
161 type="button"
162 size={compact ? "icon-xs" : "icon-sm-pill"}
163 variant="ghost"
164 aria-label="Cancel voice input"
165 onPointerDown={keepFocus}
166 onClick={props.onCancel}
167 className="shrink-0"
168 >
169 <XIcon />
170 </Button>
171 </ShortcutTooltip>
172 <div
173 className={cn(
174 "flex min-w-0 flex-1 items-center gap-2 overflow-hidden",
175 !trailing && "pe-2.5",
176 )}
177 role="status"
178 aria-live={recording ? "off" : "polite"}
179 aria-label={presentation.status ?? undefined}
180 >
181 {recording ? (
182 <RecordingStatus
183 level={props.level}
184 recordingLimitSeconds={props.recordingLimitSeconds}
185 />
186 ) : (
187 <>
188 <Spinner aria-hidden size="sm" tone="muted" className="shrink-0" />
189 <span className="min-w-0 truncate text-sm text-muted-foreground">
190 {presentation.status}
191 </span>
192 </>
193 )}
194 </div>
195 {phase === "post-processing" ? (
196 <Button
197 type="button"
198 size={compact ? "xs" : "sm-pill"}
199 variant="ghost"
200 onPointerDown={keepFocus}
201 onClick={props.onSkipPostProcessing}
202 className="shrink-0"
203 >
204 Skip
205 </Button>
206 ) : recording ? (
207 <ShortcutTooltip label="Finish voice input" shortcut={props.finishShortcutLabel}>
208 <Button
209 type="button"
210 size={compact ? "icon-xs" : "icon-sm-pill"}
211 aria-label="Finish voice input"
212 onPointerDown={keepFocus}
213 onClick={props.onStop}
214 className="shrink-0"
215 >
216 <CheckIcon />
217 </Button>
218 </ShortcutTooltip>
219 ) : null}
220 </VoiceInputPill>
221 );
222}