419 lines · 13.4 KB · TypeScript
RawDownload
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 (    <Animated.View      accessibilityElementsHidden={props.hidden}      importantForAccessibility={props.hidden ? "no-hide-descendants" : "auto"}      collapsable={false}      className={props.className}      layout={DICTATION_LAYOUT}      pointerEvents={props.hidden ? "none" : "auto"}      style={[animatedStyle, { backfaceVisibility: "hidden" }]}    >      {props.children}    </Animated.View>  );} /** 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 (    <View className="relative h-[44px] overflow-hidden">      {props.visible !== false ? (        <Animated.View          key={props.showsDictation ? "dictation" : "draft"}          className="absolute inset-0"          entering={props.showsDictation ? DICTATION_TOOLBAR_ENTERING : DRAFT_TOOLBAR_ENTERING}          exiting={props.showsDictation ? DICTATION_TOOLBAR_EXITING : DRAFT_TOOLBAR_EXITING}          style={{ backfaceVisibility: "hidden" }}        >          {props.children}        </Animated.View>      ) : null}    </View>  );} const WaveformBar = memo(function WaveformBar(props: {  readonly audioLevels: SharedValue<number[]>;  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 (    <Animated.View      className="w-0.5 rounded-full bg-foreground"      style={[{ height: WAVEFORM_BAR_HEIGHT }, animatedStyle]}    />  );}); const VoiceWaveform = memo(function VoiceWaveform(props: {  readonly audioLevels: SharedValue<number[]>;}) {  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 (    <View      accessibilityElementsHidden      importantForAccessibility="no-hide-descendants"      className="min-w-0 flex-1 flex-row items-center justify-between overflow-hidden"      style={{ height: WAVEFORM_BAR_HEIGHT }}      onLayout={handleLayout}    >      {Array.from({ length: barCount }, (_, index) => (        <WaveformBar          key={index}          audioLevels={props.audioLevels}          sampleIndex={VOICE_WAVEFORM_SAMPLE_COUNT - barCount + index}        />      ))}    </View>  );}); 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 (    <Pressable      accessibilityLabel={props.accessibilityLabel}      accessibilityRole="button"      accessibilityState={{ busy: props.loading, disabled: props.disabled }}      className="size-[44px] shrink-0 items-center justify-center active:opacity-70"      disabled={props.disabled}      onPress={props.onPress}      style={{ opacity: props.disabled && !props.loading ? 0.4 : 1 }}    >      <View        className={cn(          "items-center justify-center",          variant === "primary" ? "size-[30px] rounded-full bg-subtle" : "size-[44px]",        )}      >        {variant === "primary" ? (          <Animated.View            className="absolute inset-0 rounded-full bg-primary"            style={primaryStyle}          />        ) : null}        <View className="absolute inset-0 items-center justify-center">          {props.loading ? (            <ActivityIndicator size="small" colorClassName="accent-icon-muted" />          ) : (            <SymbolView              name={props.icon}              size={variant === "primary" ? 16 : 20}              weight={variant === "primary" ? "semibold" : "regular"}              tintColorClassName={                variant === "primary" ? "accent-primary-foreground" : "accent-icon"              }              type="monochrome"            />          )}        </View>      </View>    </Pressable>  );} export function ComposerDictationStatus(props: {  readonly audioLevels: SharedValue<number[]>;  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 (    <View className="relative h-11 min-w-0 flex-1 justify-center">      {isError ? (        <View className="min-w-0 flex-row items-center gap-1.5 px-2">          <Text className="min-w-0 flex-1 text-sm text-red-400" numberOfLines={2}>            {props.presentation.statusLabel}          </Text>          <Pressable            accessibilityLabel="Dismiss voice input error"            accessibilityRole="button"            className="size-7 items-center justify-center active:opacity-70"            hitSlop={8}            onPress={props.onDismissError}          >            <SymbolView              name="xmark"              size={12}              tintColorClassName="accent-icon-muted"              type="monochrome"            />          </Pressable>        </View>      ) : (        <View          accessible          accessibilityLabel={props.presentation.statusLabel}          accessibilityLiveRegion={props.phase === "recording" ? "none" : "polite"}          className="h-11"        >          <Animated.View            className="absolute inset-0 min-w-0 flex-row items-center gap-2 px-1"            style={waveformStyle}          >            <VoiceWaveform audioLevels={props.audioLevels} />            <Text              className="text-xs text-foreground-muted"              numberOfLines={1}              style={{ fontVariant: ["tabular-nums"] }}            >              {elapsedLabel}            </Text>          </Animated.View>          <Animated.View className="absolute inset-0 justify-center px-2" style={labelStyle}>            <Text className="text-center text-sm text-foreground-muted" numberOfLines={1}>              {props.presentation.statusLabel}            </Text>          </Animated.View>        </View>      )}    </View>  );} export function ComposerDictationCancelAction(props: {  readonly presentation: VoiceComposerPresentation;  readonly onCancel: () => void;}) {  if (props.presentation.leadingAction !== "cancel") return null;  return (    <VoiceActionButton      accessibilityLabel="Cancel dictation"      icon="xmark"      onPress={props.onCancel}    />  );} 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 (      <VoiceActionButton        accessibilityLabel={          props.presentation.confirmationEnabled            ? "Finish dictation"            : (props.presentation.statusLabel ?? "Preparing voice input")        }        disabled={!props.presentation.confirmationEnabled}        icon="checkmark"        loading={!props.presentation.confirmationEnabled}        onPress={props.onConfirm}        variant="primary"      />    );  }   return <ComposerDictationStartAction {...props} />;} 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 (    <VoiceActionButton      accessibilityLabel={openSettings ? "Open microphone settings" : "Start dictation"}      disabled={props.disabled}      icon="mic"      onPress={        openSettings          ? () => {              props.onCancel();              void Linking.openSettings();            }          : props.onStart      }    />  );}