Skip to content
Source
Sign inSign up
Open History to see the last commit

ComposerDictationControl.tsx

463 lines14.7 KB

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