| 1 | import type { VoiceInputPhase, VoiceInputState } from "@t3tools/client-runtime/voice-input"; |
| 2 | import { memo, useCallback, useLayoutEffect, useState, type ReactNode } from "react"; |
| 3 | import { |
| 4 | Linking, |
| 5 | Platform, |
| 6 | Pressable, |
| 7 | Text as SystemText, |
| 8 | View, |
| 9 | type LayoutChangeEvent, |
| 10 | } from "react-native"; |
| 11 | import 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 | |
| 22 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 23 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 25 | import { cn } from "../../lib/cn"; |
| 26 | import type { VoiceComposerPresentation } from "./voiceInputPresentation"; |
| 27 | import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; |
| 28 | |
| 29 | const DICTATION_TIMING = { |
| 30 | duration: 220, |
| 31 | easing: Easing.out(Easing.cubic), |
| 32 | reduceMotion: ReduceMotion.System, |
| 33 | } as const; |
| 34 | const DICTATION_LAYOUT = |
| 35 | Platform.OS === "android" |
| 36 | ? undefined |
| 37 | : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System); |
| 38 | const TOOLBAR_FLIP_TIMING = { |
| 39 | duration: 260, |
| 40 | easing: Easing.inOut(Easing.cubic), |
| 41 | reduceMotion: ReduceMotion.System, |
| 42 | } as const; |
| 43 | const TOOLBAR_HALF_HEIGHT = 22; |
| 44 | const TOOLBAR_PERSPECTIVE = 600; |
| 45 | |
| 46 | /** Moves each face around the same horizontal axis, keeping their edges together. */ |
| 47 | function 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 | |
| 84 | const DRAFT_TOOLBAR_ENTERING = toolbarFlip(90, 0); |
| 85 | const DRAFT_TOOLBAR_EXITING = toolbarFlip(0, 90); |
| 86 | const DICTATION_TOOLBAR_ENTERING = toolbarFlip(-90, 0); |
| 87 | const DICTATION_TOOLBAR_EXITING = toolbarFlip(0, -90); |
| 88 | const WAVEFORM_BAR_HEIGHT = 32; |
| 89 | const WAVEFORM_MIN_BAR_HEIGHT = 2; |
| 90 | const WAVEFORM_BAR_SPACING = 5; |
| 91 | const 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. */ |
| 98 | export 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. */ |
| 136 | export 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 | |
| 158 | const 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 | |
| 188 | const COMPACT_WAVEFORM_BAR_COUNT = 8; |
| 189 | const COMPACT_WAVEFORM_BAR_HEIGHT = 14; |
| 190 | |
| 191 | /** A fixed-size waveform of the latest samples, for tight spaces like the global dictation pill. */ |
| 192 | export 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 | |
| 214 | const 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 | */ |
| 253 | export 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 | |
| 268 | function 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 | |
| 325 | export 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 | |
| 395 | export 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 | |
| 409 | export 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 | |
| 438 | export 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 | } |