import type { VoiceInputPhase, VoiceInputState } from "@t3tools/client-runtime/voice-input"; import { memo, useCallback, useLayoutEffect, useState, type ReactNode } from "react"; import { ActivityIndicator, Linking, Platform, Pressable, View, type LayoutChangeEvent, } from "react-native"; import Animated, { Easing, LinearTransition, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming, type EntryExitAnimationFunction, type SharedValue, } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { cn } from "../../lib/cn"; import type { VoiceComposerPresentation } from "./voiceInputPresentation"; import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; const DICTATION_TIMING = { duration: 220, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System, } as const; const DICTATION_LAYOUT = Platform.OS === "android" ? undefined : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System); const TOOLBAR_FLIP_TIMING = { duration: 260, easing: Easing.inOut(Easing.cubic), reduceMotion: ReduceMotion.System, } as const; const TOOLBAR_HALF_HEIGHT = 22; const TOOLBAR_PERSPECTIVE = 600; /** Moves each face around the same horizontal axis, keeping their edges together. */ function toolbarFlip(fromDegrees: number, toDegrees: number): EntryExitAnimationFunction { return () => { "worklet"; const fromRadians = (fromDegrees * Math.PI) / 180; const toRadians = (toDegrees * Math.PI) / 180; const fromSine = Math.sin(fromRadians); const toSine = Math.sin(toRadians); return { initialValues: { opacity: fromDegrees === 0 ? 1 : 0, transform: [ { perspective: TOOLBAR_PERSPECTIVE }, { translateY: -TOOLBAR_HALF_HEIGHT * fromSine }, { rotateX: `${fromDegrees}deg` }, ], }, animations: { opacity: withTiming(toDegrees === 0 ? 1 : 0, TOOLBAR_FLIP_TIMING), transform: [ { perspective: TOOLBAR_PERSPECTIVE }, { translateY: withTiming(-TOOLBAR_HALF_HEIGHT * toSine, { ...TOOLBAR_FLIP_TIMING, easing: (time) => { const angle = fromRadians + (toRadians - fromRadians) * TOOLBAR_FLIP_TIMING.easing(time); return (Math.sin(angle) - fromSine) / (toSine - fromSine); }, }), }, { rotateX: withTiming(`${toDegrees}deg`, TOOLBAR_FLIP_TIMING) }, ], }, }; }; } const DRAFT_TOOLBAR_ENTERING = toolbarFlip(90, 0); const DRAFT_TOOLBAR_EXITING = toolbarFlip(0, 90); const DICTATION_TOOLBAR_ENTERING = toolbarFlip(-90, 0); const DICTATION_TOOLBAR_EXITING = toolbarFlip(0, -90); const WAVEFORM_BAR_HEIGHT = 32; const WAVEFORM_MIN_BAR_HEIGHT = 2; const WAVEFORM_BAR_SPACING = 5; const WAVEFORM_TIMING = { duration: 100, easing: Easing.out(Easing.quad), reduceMotion: ReduceMotion.System, } as const; /** Rotates the compact draft away without unmounting or resizing its native editor. */ export function ComposerDictationDraftContent(props: { readonly children: ReactNode; readonly className?: string; readonly compact: boolean; readonly hidden: boolean; }) { const rotation = useSharedValue(props.hidden ? 1 : 0); useLayoutEffect(() => { rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING); }, [props.hidden, rotation]); const compact = props.compact; const animatedStyle = useAnimatedStyle(() => ({ opacity: compact ? 1 - rotation.value : 1, transform: compact ? [ { perspective: TOOLBAR_PERSPECTIVE }, { translateY: -TOOLBAR_HALF_HEIGHT * Math.sin((rotation.value * Math.PI) / 2) }, { rotateX: `${rotation.value * 90}deg` }, ] : [], })); return ( {props.children} ); } /** Flips the entire row while keeping the outgoing controls intact until it leaves. */ export function ComposerDictationToolbar(props: { readonly children: ReactNode; readonly showsDictation: boolean; readonly visible?: boolean; }) { return ( {props.visible !== false ? ( {props.children} ) : null} ); } const WaveformBar = memo(function WaveformBar(props: { readonly audioLevels: SharedValue; readonly sampleIndex: number; }) { const { audioLevels, sampleIndex } = props; const animatedStyle = useAnimatedStyle(() => { const level = audioLevels.value[sampleIndex] ?? 0; return { opacity: withTiming(0.22 + level * 0.78, WAVEFORM_TIMING), transform: [ { scaleY: withTiming( (WAVEFORM_MIN_BAR_HEIGHT + level * (WAVEFORM_BAR_HEIGHT - WAVEFORM_MIN_BAR_HEIGHT)) / WAVEFORM_BAR_HEIGHT, WAVEFORM_TIMING, ), }, ], }; }); return ( ); }); const VoiceWaveform = memo(function VoiceWaveform(props: { readonly audioLevels: SharedValue; }) { const [barCount, setBarCount] = useState(0); const handleLayout = useCallback((event: LayoutChangeEvent) => { setBarCount( Math.max( 1, Math.min( VOICE_WAVEFORM_SAMPLE_COUNT, Math.floor(event.nativeEvent.layout.width / WAVEFORM_BAR_SPACING), ), ), ); }, []); return ( {Array.from({ length: barCount }, (_, index) => ( ))} ); }); function VoiceActionButton(props: { readonly accessibilityLabel: string; readonly disabled?: boolean; readonly icon: AppSymbolName; readonly loading?: boolean; readonly onPress: () => void; readonly variant?: "plain" | "primary"; }) { const variant = props.variant ?? "plain"; const loadingVisibility = useSharedValue(props.loading ? 1 : 0); useLayoutEffect(() => { loadingVisibility.value = withTiming(props.loading ? 1 : 0, DICTATION_TIMING); }, [loadingVisibility, props.loading]); const primaryStyle = useAnimatedStyle(() => ({ opacity: 1 - loadingVisibility.value })); return ( {variant === "primary" ? ( ) : null} {props.loading ? ( ) : ( )} ); } export function ComposerDictationStatus(props: { readonly audioLevels: SharedValue; readonly elapsedSeconds: number; readonly phase: VoiceInputPhase; readonly presentation: VoiceComposerPresentation; readonly onDismissError: () => void; }) { const recordingVisibility = useSharedValue(props.phase === "recording" ? 1 : 0); useLayoutEffect(() => { recordingVisibility.value = withTiming(props.phase === "recording" ? 1 : 0, DICTATION_TIMING); }, [props.phase, recordingVisibility]); const waveformStyle = useAnimatedStyle(() => ({ opacity: recordingVisibility.value, })); const labelStyle = useAnimatedStyle(() => ({ opacity: 1 - recordingVisibility.value, })); if (!props.presentation.statusLabel) return null; const isError = props.presentation.statusKind === "error"; const elapsedLabel = `${Math.floor(props.elapsedSeconds / 60)}:${String(props.elapsedSeconds % 60).padStart(2, "0")}`; return ( {isError ? ( {props.presentation.statusLabel} ) : ( {elapsedLabel} {props.presentation.statusLabel} )} ); } export function ComposerDictationCancelAction(props: { readonly presentation: VoiceComposerPresentation; readonly onCancel: () => void; }) { if (props.presentation.leadingAction !== "cancel") return null; return ( ); } export function ComposerDictationPrimaryAction(props: { readonly state: VoiceInputState; readonly presentation: VoiceComposerPresentation; readonly isAvailable: boolean; readonly disabled?: boolean; readonly onStart: () => void; readonly onConfirm: () => void; readonly onCancel: () => void; }) { if (props.presentation.trailingAction === "confirm") { return ( ); } return ; } export function ComposerDictationStartAction(props: { readonly state: VoiceInputState; readonly isAvailable: boolean; readonly disabled?: boolean; readonly onStart: () => void; readonly onCancel: () => void; }) { if (!props.isAvailable) return null; const openSettings = props.state.phase === "error" && props.state.errorAction === "settings"; return ( { props.onCancel(); void Linking.openSettings(); } : props.onStart } /> ); }