1import type { VoiceInputPhase, VoiceInputState } from "@t3tools/client-runtime/voice-input";
2import { memo, useCallback, useLayoutEffect, useState, type ReactNode } from "react";
3import {
4 Linking,
5 Platform,
6 Pressable,
7 Text as SystemText,
8 View,
9 type LayoutChangeEvent,
10} from "react-native";
11import Animated, {
12 Easing,
13 LinearTransition,
14 ReduceMotion,
15 useAnimatedStyle,
16 useSharedValue,
17 withTiming,
18 type EntryExitAnimationFunction,
19 type SharedValue,
20} from "react-native-reanimated";
21
22import { AppActivityIndicator } from "../../components/AppActivityIndicator";
23import { AppText as Text } from "../../components/AppText";
24import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
25import { cn } from "../../lib/cn";
26import type { VoiceComposerPresentation } from "./voiceInputPresentation";
27import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
28
29const DICTATION_TIMING = {
30 duration: 220,
31 easing: Easing.out(Easing.cubic),
32 reduceMotion: ReduceMotion.System,
33} as const;
34const DICTATION_LAYOUT =
35 Platform.OS === "android"
36 ? undefined
37 : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System);
38const TOOLBAR_FLIP_TIMING = {
39 duration: 260,
40 easing: Easing.inOut(Easing.cubic),
41 reduceMotion: ReduceMotion.System,
42} as const;
43const TOOLBAR_HALF_HEIGHT = 22;
44const TOOLBAR_PERSPECTIVE = 600;
45
46/** Moves each face around the same horizontal axis, keeping their edges together. */
47function toolbarFlip(fromDegrees: number, toDegrees: number): EntryExitAnimationFunction {
48 return () => {
49 "worklet";
50 const fromRadians = (fromDegrees * Math.PI) / 180;
51 const toRadians = (toDegrees * Math.PI) / 180;
52 const fromSine = Math.sin(fromRadians);
53 const toSine = Math.sin(toRadians);
54 return {
55 initialValues: {
56 opacity: fromDegrees === 0 ? 1 : 0,
57 transform: [
58 { perspective: TOOLBAR_PERSPECTIVE },
59 { translateY: -TOOLBAR_HALF_HEIGHT * fromSine },
60 { rotateX: `${fromDegrees}deg` },
61 ],
62 },
63 animations: {
64 opacity: withTiming(toDegrees === 0 ? 1 : 0, TOOLBAR_FLIP_TIMING),
65 transform: [
66 { perspective: TOOLBAR_PERSPECTIVE },
67 {
68 translateY: withTiming(-TOOLBAR_HALF_HEIGHT * toSine, {
69 ...TOOLBAR_FLIP_TIMING,
70 easing: (time) => {
71 const angle =
72 fromRadians + (toRadians - fromRadians) * TOOLBAR_FLIP_TIMING.easing(time);
73 return (Math.sin(angle) - fromSine) / (toSine - fromSine);
74 },
75 }),
76 },
77 { rotateX: withTiming(`${toDegrees}deg`, TOOLBAR_FLIP_TIMING) },
78 ],
79 },
80 };
81 };
82}
83
84const DRAFT_TOOLBAR_ENTERING = toolbarFlip(90, 0);
85const DRAFT_TOOLBAR_EXITING = toolbarFlip(0, 90);
86const DICTATION_TOOLBAR_ENTERING = toolbarFlip(-90, 0);
87const DICTATION_TOOLBAR_EXITING = toolbarFlip(0, -90);
88const WAVEFORM_BAR_HEIGHT = 32;
89const WAVEFORM_MIN_BAR_HEIGHT = 2;
90const WAVEFORM_BAR_SPACING = 5;
91const WAVEFORM_TIMING = {
92 duration: 100,
93 easing: Easing.out(Easing.quad),
94 reduceMotion: ReduceMotion.System,
95} as const;
96
97/** Rotates the compact draft away without unmounting or resizing its native editor. */
98export function ComposerDictationDraftContent(props: {
99 readonly children: ReactNode;
100 readonly className?: string;
101 readonly compact: boolean;
102 readonly hidden: boolean;
103}) {
104 const rotation = useSharedValue(props.hidden ? 1 : 0);
105 useLayoutEffect(() => {
106 rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING);
107 }, [props.hidden, rotation]);
108 const compact = props.compact;
109 const animatedStyle = useAnimatedStyle(() => ({
110 opacity: compact ? 1 - rotation.value : 1,
111 transform: compact
112 ? [
113 { perspective: TOOLBAR_PERSPECTIVE },
114 { translateY: -TOOLBAR_HALF_HEIGHT * Math.sin((rotation.value * Math.PI) / 2) },
115 { rotateX: `${rotation.value * 90}deg` },
116 ]
117 : [],
118 }));
119
120 return (
121 <Animated.View
122 accessibilityElementsHidden={props.hidden}
123 importantForAccessibility={props.hidden ? "no-hide-descendants" : "auto"}
124 collapsable={false}
125 className={props.className}
126 layout={DICTATION_LAYOUT}
127 pointerEvents={props.hidden ? "none" : "auto"}
128 style={[animatedStyle, { backfaceVisibility: "hidden" }]}
129 >
130 {props.children}
131 </Animated.View>
132 );
133}
134
135/** Flips the entire row while keeping the outgoing controls intact until it leaves. */
136export function ComposerDictationToolbar(props: {
137 readonly children: ReactNode;
138 readonly showsDictation: boolean;
139 readonly visible?: boolean;
140}) {
141 return (
142 <View className="relative h-[44px] overflow-hidden">
143 {props.visible !== false ? (
144 <Animated.View
145 key={props.showsDictation ? "dictation" : "draft"}
146 className="absolute inset-0"
147 entering={props.showsDictation ? DICTATION_TOOLBAR_ENTERING : DRAFT_TOOLBAR_ENTERING}
148 exiting={props.showsDictation ? DICTATION_TOOLBAR_EXITING : DRAFT_TOOLBAR_EXITING}
149 style={{ backfaceVisibility: "hidden" }}
150 >
151 {props.children}
152 </Animated.View>
153 ) : null}
154 </View>
155 );
156}
157
158const WaveformBar = memo(function WaveformBar(props: {
159 readonly audioLevels: SharedValue<number[]>;
160 readonly sampleIndex: number;
161 readonly height?: number;
162}) {
163 const { audioLevels, sampleIndex } = props;
164 const height = props.height ?? WAVEFORM_BAR_HEIGHT;
165 const animatedStyle = useAnimatedStyle(() => {
166 const level = audioLevels.value[sampleIndex] ?? 0;
167 return {
168 opacity: withTiming(0.22 + level * 0.78, WAVEFORM_TIMING),
169 transform: [
170 {
171 scaleY: withTiming(
172 (WAVEFORM_MIN_BAR_HEIGHT + level * (height - WAVEFORM_MIN_BAR_HEIGHT)) / height,
173 WAVEFORM_TIMING,
174 ),
175 },
176 ],
177 };
178 });
179
180 return (
181 <Animated.View
182 className="w-0.5 rounded-full bg-foreground"
183 style={[{ height }, animatedStyle]}
184 />
185 );
186});
187
188const COMPACT_WAVEFORM_BAR_COUNT = 8;
189const COMPACT_WAVEFORM_BAR_HEIGHT = 14;
190
191/** A fixed-size waveform of the latest samples, for tight spaces like the global dictation pill. */
192export const CompactVoiceWaveform = memo(function CompactVoiceWaveform(props: {
193 readonly audioLevels: SharedValue<number[]>;
194}) {
195 return (
196 <View
197 accessibilityElementsHidden
198 importantForAccessibility="no-hide-descendants"
199 className="flex-row items-center gap-0.5"
200 style={{ height: COMPACT_WAVEFORM_BAR_HEIGHT }}
201 >
202 {Array.from({ length: COMPACT_WAVEFORM_BAR_COUNT }, (_, index) => (
203 <WaveformBar
204 key={index}
205 audioLevels={props.audioLevels}
206 height={COMPACT_WAVEFORM_BAR_HEIGHT}
207 sampleIndex={VOICE_WAVEFORM_SAMPLE_COUNT - COMPACT_WAVEFORM_BAR_COUNT + index}
208 />
209 ))}
210 </View>
211 );
212});
213
214const VoiceWaveform = memo(function VoiceWaveform(props: {
215 readonly audioLevels: SharedValue<number[]>;
216}) {
217 const [barCount, setBarCount] = useState(0);
218 const handleLayout = useCallback((event: LayoutChangeEvent) => {
219 setBarCount(
220 Math.max(
221 1,
222 Math.min(
223 VOICE_WAVEFORM_SAMPLE_COUNT,
224 Math.floor(event.nativeEvent.layout.width / WAVEFORM_BAR_SPACING),
225 ),
226 ),
227 );
228 }, []);
229
230 return (
231 <View
232 accessibilityElementsHidden
233 importantForAccessibility="no-hide-descendants"
234 className="min-w-0 flex-1 flex-row items-center justify-between overflow-hidden"
235 style={{ height: WAVEFORM_BAR_HEIGHT }}
236 onLayout={handleLayout}
237 >
238 {Array.from({ length: barCount }, (_, index) => (
239 <WaveformBar
240 key={index}
241 audioLevels={props.audioLevels}
242 sampleIndex={VOICE_WAVEFORM_SAMPLE_COUNT - barCount + index}
243 />
244 ))}
245 </View>
246 );
247});
248
249/**
250 * Renders the recording time in the system font: DM Sans has no tabular
251 * figures, so `tabular-nums` alone would still let the width jitter.
252 */
253export function DictationElapsedTime(props: {
254 readonly seconds: number;
255 readonly className?: string;
256}) {
257 return (
258 <SystemText
259 className={cn("text-xs", props.className)}
260 numberOfLines={1}
261 style={{ fontVariant: ["tabular-nums"] }}
262 >
263 {Math.floor(props.seconds / 60)}:{String(props.seconds % 60).padStart(2, "0")}
264 </SystemText>
265 );
266}
267
268function VoiceActionButton(props: {
269 readonly accessibilityLabel: string;
270 readonly disabled?: boolean;
271 readonly icon: AppSymbolName;
272 readonly loading?: boolean;
273 readonly onPress: () => void;
274 readonly variant?: "plain" | "primary";
275}) {
276 const variant = props.variant ?? "plain";
277 const loadingVisibility = useSharedValue(props.loading ? 1 : 0);
278 useLayoutEffect(() => {
279 loadingVisibility.value = withTiming(props.loading ? 1 : 0, DICTATION_TIMING);
280 }, [loadingVisibility, props.loading]);
281 const primaryStyle = useAnimatedStyle(() => ({ opacity: 1 - loadingVisibility.value }));
282
283 return (
284 <Pressable
285 accessibilityLabel={props.accessibilityLabel}
286 accessibilityRole="button"
287 accessibilityState={{ busy: props.loading, disabled: props.disabled }}
288 className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
289 disabled={props.disabled}
290 onPress={props.onPress}
291 style={{ opacity: props.disabled && !props.loading ? 0.4 : 1 }}
292 >
293 <View
294 className={cn(
295 "items-center justify-center",
296 variant === "primary" ? "size-[30px] rounded-full bg-subtle" : "size-[44px]",
297 )}
298 >
299 {variant === "primary" ? (
300 <Animated.View
301 className="absolute inset-0 rounded-full bg-primary"
302 style={primaryStyle}
303 />
304 ) : null}
305 <View className="absolute inset-0 items-center justify-center">
306 {props.loading ? (
307 <AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
308 ) : (
309 <SymbolView
310 name={props.icon}
311 size={variant === "primary" ? 16 : 20}
312 weight={variant === "primary" ? "semibold" : "regular"}
313 tintColorClassName={
314 variant === "primary" ? "accent-primary-foreground" : "accent-icon"
315 }
316 type="monochrome"
317 />
318 )}
319 </View>
320 </View>
321 </Pressable>
322 );
323}
324
325export function ComposerDictationStatus(props: {
326 readonly audioLevels: SharedValue<number[]>;
327 readonly elapsedSeconds: number;
328 readonly phase: VoiceInputPhase;
329 readonly presentation: VoiceComposerPresentation;
330 readonly onDismissError: () => void;
331}) {
332 const recordingVisibility = useSharedValue(props.phase === "recording" ? 1 : 0);
333 useLayoutEffect(() => {
334 recordingVisibility.value = withTiming(props.phase === "recording" ? 1 : 0, DICTATION_TIMING);
335 }, [props.phase, recordingVisibility]);
336 const waveformStyle = useAnimatedStyle(() => ({
337 opacity: recordingVisibility.value,
338 }));
339 const labelStyle = useAnimatedStyle(() => ({
340 opacity: 1 - recordingVisibility.value,
341 }));
342
343 if (!props.presentation.statusLabel) return null;
344 const isError = props.presentation.statusKind === "error";
345 return (
346 <View className="relative h-11 min-w-0 flex-1 justify-center">
347 {isError ? (
348 <View className="min-w-0 flex-row items-center gap-1.5 px-2">
349 <Text className="min-w-0 flex-1 text-sm text-danger-foreground" numberOfLines={2}>
350 {props.presentation.statusLabel}
351 </Text>
352 <Pressable
353 accessibilityLabel="Dismiss voice input error"
354 accessibilityRole="button"
355 className="size-7 items-center justify-center active:opacity-70"
356 hitSlop={8}
357 onPress={props.onDismissError}
358 >
359 <SymbolView
360 name="xmark"
361 size={12}
362 tintColorClassName="accent-icon-muted"
363 type="monochrome"
364 />
365 </Pressable>
366 </View>
367 ) : (
368 <View
369 accessible
370 accessibilityLabel={props.presentation.statusLabel}
371 accessibilityLiveRegion={props.phase === "recording" ? "none" : "polite"}
372 className="h-11"
373 >
374 <Animated.View
375 className="absolute inset-0 min-w-0 flex-row items-center gap-2 px-1"
376 style={waveformStyle}
377 >
378 <VoiceWaveform audioLevels={props.audioLevels} />
379 <DictationElapsedTime
380 className="text-foreground-muted"
381 seconds={props.elapsedSeconds}
382 />
383 </Animated.View>
384 <Animated.View className="absolute inset-0 justify-center px-2" style={labelStyle}>
385 <Text className="text-center text-sm text-foreground-muted" numberOfLines={1}>
386 {props.presentation.statusLabel}
387 </Text>
388 </Animated.View>
389 </View>
390 )}
391 </View>
392 );
393}
394
395export function ComposerDictationCancelAction(props: {
396 readonly presentation: VoiceComposerPresentation;
397 readonly onCancel: () => void;
398}) {
399 if (props.presentation.leadingAction !== "cancel") return null;
400 return (
401 <VoiceActionButton
402 accessibilityLabel="Cancel dictation"
403 icon="xmark"
404 onPress={props.onCancel}
405 />
406 );
407}
408
409export function ComposerDictationPrimaryAction(props: {
410 readonly state: VoiceInputState;
411 readonly presentation: VoiceComposerPresentation;
412 readonly isAvailable: boolean;
413 readonly disabled?: boolean;
414 readonly onStart: () => void;
415 readonly onConfirm: () => void;
416 readonly onCancel: () => void;
417}) {
418 if (props.presentation.trailingAction === "confirm") {
419 return (
420 <VoiceActionButton
421 accessibilityLabel={
422 props.presentation.confirmationEnabled
423 ? "Finish dictation"
424 : (props.presentation.statusLabel ?? "Preparing voice input")
425 }
426 disabled={!props.presentation.confirmationEnabled}
427 icon="checkmark"
428 loading={!props.presentation.confirmationEnabled}
429 onPress={props.onConfirm}
430 variant="primary"
431 />
432 );
433 }
434
435 return <ComposerDictationStartAction {...props} />;
436}
437
438export function ComposerDictationStartAction(props: {
439 readonly state: VoiceInputState;
440 readonly isAvailable: boolean;
441 readonly disabled?: boolean;
442 readonly onStart: () => void;
443 readonly onCancel: () => void;
444}) {
445 if (!props.isAvailable) return null;
446 const openSettings = props.state.phase === "error" && props.state.errorAction === "settings";
447 return (
448 <VoiceActionButton
449 accessibilityLabel={openSettings ? "Open microphone settings" : "Start dictation"}
450 disabled={props.disabled}
451 icon="mic"
452 onPress={
453 openSettings
454 ? () => {
455 props.onCancel();
456 void Linking.openSettings();
457 }
458 : props.onStart
459 }
460 />
461 );
462}