| author | |
| committer | |
| log | 2a4ee339827e480da6ad0139585cf634a46ff73e |
| tree | c512bb96178cf608a0a0e2ea60abdc12662993ad |
| parent | 61eb4af69c38c1f39e58ed76aa695b751f9f9baf |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>14 files changed, 737 insertions(+), 66 deletions(-)
apps/mobile/src/Stack.tsx+8| ... | @@ -102,6 +102,7 @@ import { | ... | @@ -102,6 +102,7 @@ import { |
| 102 | import { ScheduledTaskEditorProvider } from "./features/settings/scheduled-task-editor"; | 102 | import { ScheduledTaskEditorProvider } from "./features/settings/scheduled-task-editor"; |
| 103 | import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; | 103 | import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; |
| 104 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 104 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 105 | import { SettingsMicrophoneRouteScreen } from "./features/settings/SettingsMicrophoneRouteScreen"; | ||
| 105 | import { | 106 | import { |
| 106 | SettingsOpenSourceLicenseRouteScreen, | 107 | SettingsOpenSourceLicenseRouteScreen, |
| 107 | SettingsOpenSourceLicensesRouteScreen, | 108 | SettingsOpenSourceLicensesRouteScreen, |
| ... | @@ -310,6 +311,13 @@ const SettingsContentStack = createV5SheetStackNavigator({ | ... | @@ -310,6 +311,13 @@ const SettingsContentStack = createV5SheetStackNavigator({ |
| 310 | title: "Keyboard", | 311 | title: "Keyboard", |
| 311 | }, | 312 | }, |
| 312 | }), | 313 | }), |
| 314 | SettingsMicrophone: createNativeStackScreen({ | ||
| 315 | screen: SettingsMicrophoneRouteScreen, | ||
| 316 | linking: "microphone", | ||
| 317 | options: { | ||
| 318 | title: "Microphone", | ||
| 319 | }, | ||
| 320 | }), | ||
| 313 | SettingsFollowUp: createNativeStackScreen({ | 321 | SettingsFollowUp: createNativeStackScreen({ |
| 314 | screen: SettingsFollowUpRouteScreen, | 322 | screen: SettingsFollowUpRouteScreen, |
| 315 | linking: "follow-ups", | 323 | linking: "follow-ups", |
apps/mobile/src/components/AppSymbol.tsx+7| ... | @@ -28,6 +28,7 @@ import IconBolt from "@tabler/icons-react-native/IconBolt"; | ... | @@ -28,6 +28,7 @@ import IconBolt from "@tabler/icons-react-native/IconBolt"; |
| 28 | import IconBox from "@tabler/icons-react-native/IconBox"; | 28 | import IconBox from "@tabler/icons-react-native/IconBox"; |
| 29 | import IconBrain from "@tabler/icons-react-native/IconBrain"; | 29 | import IconBrain from "@tabler/icons-react-native/IconBrain"; |
| 30 | import IconCamera from "@tabler/icons-react-native/IconCamera"; | 30 | import IconCamera from "@tabler/icons-react-native/IconCamera"; |
| 31 | import IconCar from "@tabler/icons-react-native/IconCar"; | ||
| 31 | import IconChartBar from "@tabler/icons-react-native/IconChartBar"; | 32 | import IconChartBar from "@tabler/icons-react-native/IconChartBar"; |
| 32 | import IconCheck from "@tabler/icons-react-native/IconCheck"; | 33 | import IconCheck from "@tabler/icons-react-native/IconCheck"; |
| 33 | import IconCloud from "@tabler/icons-react-native/IconCloud"; | 34 | import IconCloud from "@tabler/icons-react-native/IconCloud"; |
| ... | @@ -58,6 +59,7 @@ import IconFilter from "@tabler/icons-react-native/IconFilter"; | ... | @@ -58,6 +59,7 @@ import IconFilter from "@tabler/icons-react-native/IconFilter"; |
| 58 | import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; | 59 | import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; |
| 59 | import IconFolder from "@tabler/icons-react-native/IconFolder"; | 60 | import IconFolder from "@tabler/icons-react-native/IconFolder"; |
| 60 | import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; | 61 | import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; |
| 62 | import IconHeadphones from "@tabler/icons-react-native/IconHeadphones"; | ||
| 61 | import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; | 63 | import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; |
| 62 | import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; | 64 | import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; |
| 63 | import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; | 65 | import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; |
| ... | @@ -83,6 +85,7 @@ import IconMoon from "@tabler/icons-react-native/IconMoon"; | ... | @@ -83,6 +85,7 @@ import IconMoon from "@tabler/icons-react-native/IconMoon"; |
| 83 | import IconNetwork from "@tabler/icons-react-native/IconNetwork"; | 85 | import IconNetwork from "@tabler/icons-react-native/IconNetwork"; |
| 84 | import IconPalette from "@tabler/icons-react-native/IconPalette"; | 86 | import IconPalette from "@tabler/icons-react-native/IconPalette"; |
| 85 | import IconPencil from "@tabler/icons-react-native/IconPencil"; | 87 | import IconPencil from "@tabler/icons-react-native/IconPencil"; |
| 88 | import IconPlug from "@tabler/icons-react-native/IconPlug"; | ||
| 86 | import IconPhoto from "@tabler/icons-react-native/IconPhoto"; | 89 | import IconPhoto from "@tabler/icons-react-native/IconPhoto"; |
| 87 | import IconPin from "@tabler/icons-react-native/IconPin"; | 90 | import IconPin from "@tabler/icons-react-native/IconPin"; |
| 88 | import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; | 91 | import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; |
| ... | @@ -146,7 +149,9 @@ const ANDROID_ICON_BY_SF_SYMBOL = { | ... | @@ -146,7 +149,9 @@ const ANDROID_ICON_BY_SF_SYMBOL = { |
| 146 | "bolt.circle": IconBolt, | 149 | "bolt.circle": IconBolt, |
| 147 | "bolt.horizontal.circle": IconBolt, | 150 | "bolt.horizontal.circle": IconBolt, |
| 148 | brain: IconBrain, | 151 | brain: IconBrain, |
| 152 | "cable.connector": IconPlug, | ||
| 149 | camera: IconCamera, | 153 | camera: IconCamera, |
| 154 | car: IconCar, | ||
| 150 | "chart.bar.xaxis": IconChartBar, | 155 | "chart.bar.xaxis": IconChartBar, |
| 151 | checkmark: IconCheck, | 156 | checkmark: IconCheck, |
| 152 | "checkmark.circle": IconCircleCheck, | 157 | "checkmark.circle": IconCircleCheck, |
| ... | @@ -178,9 +183,11 @@ const ANDROID_ICON_BY_SF_SYMBOL = { | ... | @@ -178,9 +183,11 @@ const ANDROID_ICON_BY_SF_SYMBOL = { |
| 178 | gearshape: IconSettings, | 183 | gearshape: IconSettings, |
| 179 | globe: IconWorld, | 184 | globe: IconWorld, |
| 180 | hammer: IconHammer, | 185 | hammer: IconHammer, |
| 186 | headphones: IconHeadphones, | ||
| 181 | house: IconHome, | 187 | house: IconHome, |
| 182 | "info.circle": IconInfoCircle, | 188 | "info.circle": IconInfoCircle, |
| 183 | internaldrive: IconDatabase, | 189 | internaldrive: IconDatabase, |
| 190 | iphone: IconDeviceMobile, | ||
| 184 | keyboard: IconKeyboard, | 191 | keyboard: IconKeyboard, |
| 185 | laptopcomputer: IconDeviceLaptop, | 192 | laptopcomputer: IconDeviceLaptop, |
| 186 | link: IconLink, | 193 | link: IconLink, |
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+3-62| ... | @@ -13,9 +13,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; | ... | @@ -13,9 +13,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; |
| 13 | import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; | 13 | import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; |
| 14 | import { AsyncResult } from "effect/reactivity"; | 14 | import { AsyncResult } from "effect/reactivity"; |
| 15 | import * as Option from "effect/Option"; | 15 | import * as Option from "effect/Option"; |
| 16 | import { useEffect, useMemo, useRef, useState } from "react"; | 16 | import { useState } from "react"; |
| 17 | import { Alert, Platform, Pressable, View } from "react-native"; | 17 | import { Alert, Platform, Pressable, View } from "react-native"; |
| 18 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 19 | import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; | 18 | import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; |
| 20 | 19 | ||
| 21 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; | 20 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -27,6 +26,7 @@ import { environmentSession } from "../../state/session"; | ... | @@ -27,6 +26,7 @@ import { environmentSession } from "../../state/session"; |
| 27 | import { useAtomCommand } from "../../state/use-atom-command"; | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | import { connectionTone } from "../connection/connectionTone"; | 27 | import { connectionTone } from "../connection/connectionTone"; |
| 29 | import { SettingsActionRow } from "./components/SettingsActionRow"; | 28 | import { SettingsActionRow } from "./components/SettingsActionRow"; |
| 29 | import { SettingsDragHandle } from "./components/SettingsDragHandle"; | ||
| 30 | import { SettingsSection } from "./components/SettingsSection"; | 30 | import { SettingsSection } from "./components/SettingsSection"; |
| 31 | 31 | ||
| 32 | const ICON_SIZE = Platform.OS === "android" ? 24 : 22; | 32 | const ICON_SIZE = Platform.OS === "android" ? 24 : 22; |
| ... | @@ -324,7 +324,7 @@ function RouteRow(props: { | ... | @@ -324,7 +324,7 @@ function RouteRow(props: { |
| 324 | </View> | 324 | </View> |
| 325 | </View> | 325 | </View> |
| 326 | {props.editing ? ( | 326 | {props.editing ? ( |
| 327 | <DragHandle | 327 | <SettingsDragHandle |
| 328 | title={label} | 328 | title={label} |
| 329 | canMoveUp={props.position > 1} | 329 | canMoveUp={props.position > 1} |
| 330 | canMoveDown={props.position < props.count} | 330 | canMoveDown={props.position < props.count} |
| ... | @@ -337,62 +337,3 @@ function RouteRow(props: { | ... | @@ -337,62 +337,3 @@ function RouteRow(props: { |
| 337 | </Reanimated.View> | 337 | </Reanimated.View> |
| 338 | ); | 338 | ); |
| 339 | } | 339 | } |
| 340 | |||
| 341 | /** Pan recognition wins over the settings scroll view only inside the handle. */ | ||
| 342 | function DragHandle(props: { | ||
| 343 | readonly title: string; | ||
| 344 | readonly canMoveUp: boolean; | ||
| 345 | readonly canMoveDown: boolean; | ||
| 346 | readonly onStart: () => void; | ||
| 347 | readonly onMove: (translation: number) => void; | ||
| 348 | readonly onEnd: (translation: number, cancelled: boolean) => void; | ||
| 349 | readonly onStep: (direction: "up" | "down") => void; | ||
| 350 | }) { | ||
| 351 | const latest = useRef(props); | ||
| 352 | useEffect(() => { | ||
| 353 | latest.current = props; | ||
| 354 | }); | ||
| 355 | const translation = useRef(0); | ||
| 356 | const gesture = useMemo( | ||
| 357 | () => | ||
| 358 | Gesture.Pan() | ||
| 359 | .minDistance(0) | ||
| 360 | .shouldCancelWhenOutside(false) | ||
| 361 | .runOnJS(true) | ||
| 362 | .onStart(() => { | ||
| 363 | translation.current = 0; | ||
| 364 | latest.current.onStart(); | ||
| 365 | }) | ||
| 366 | .onUpdate((event) => { | ||
| 367 | translation.current = event.translationY; | ||
| 368 | latest.current.onMove(event.translationY); | ||
| 369 | }) | ||
| 370 | .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)), | ||
| 371 | [], | ||
| 372 | ); | ||
| 373 | return ( | ||
| 374 | <GestureDetector gesture={gesture}> | ||
| 375 | <View | ||
| 376 | collapsable={false} | ||
| 377 | accessible | ||
| 378 | accessibilityRole="adjustable" | ||
| 379 | accessibilityLabel={`Reorder ${props.title}`} | ||
| 380 | accessibilityActions={[ | ||
| 381 | ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []), | ||
| 382 | ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []), | ||
| 383 | ]} | ||
| 384 | onAccessibilityAction={({ nativeEvent }) => { | ||
| 385 | if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); | ||
| 386 | if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); | ||
| 387 | }} | ||
| 388 | style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }} | ||
| 389 | > | ||
| 390 | <SymbolView | ||
| 391 | name="line.3.horizontal" | ||
| 392 | size={20} | ||
| 393 | tintColorClassName="accent-foreground-muted" | ||
| 394 | /> | ||
| 395 | </View> | ||
| 396 | </GestureDetector> | ||
| 397 | ); | ||
| 398 | } |
apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx created+323| ... | @@ -0,0 +1,323 @@ | ||
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AsyncResult } from "effect/reactivity"; | ||
| 3 | import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio"; | ||
| 4 | import { useCallback, useEffect, useState } from "react"; | ||
| 5 | import { AppState, Linking, Pressable, ScrollView, View } from "react-native"; | ||
| 6 | import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | |||
| 9 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; | ||
| 10 | import { AppText as Text } from "../../components/AppText"; | ||
| 11 | import { cn } from "../../lib/cn"; | ||
| 12 | import { | ||
| 13 | isMicrophoneDevice, | ||
| 14 | type MicrophoneEntry, | ||
| 15 | type MicrophoneKind, | ||
| 16 | microphoneEntryKey, | ||
| 17 | microphoneOrder, | ||
| 18 | } from "../../lib/microphonePriority"; | ||
| 19 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 20 | import { SettingsActionRow } from "./components/SettingsActionRow"; | ||
| 21 | import { SettingsDragHandle } from "./components/SettingsDragHandle"; | ||
| 22 | import { SettingsSection } from "./components/SettingsSection"; | ||
| 23 | |||
| 24 | const REMOVE_SIZE = 20; | ||
| 25 | |||
| 26 | type MicrophonePermission = "checking" | "granted" | "ask" | "denied"; | ||
| 27 | |||
| 28 | /** Re-reads microphone access whenever the app returns from system Settings. */ | ||
| 29 | function useMicrophonePermission() { | ||
| 30 | const [permission, setPermission] = useState<MicrophonePermission>("checking"); | ||
| 31 | const apply = useCallback( | ||
| 32 | (response: { readonly granted: boolean; readonly canAskAgain: boolean }) => | ||
| 33 | setPermission(response.granted ? "granted" : response.canAskAgain ? "ask" : "denied"), | ||
| 34 | [], | ||
| 35 | ); | ||
| 36 | const refresh = useCallback( | ||
| 37 | () => void getRecordingPermissionsAsync().then(apply, () => setPermission("ask")), | ||
| 38 | [apply], | ||
| 39 | ); | ||
| 40 | useEffect(() => { | ||
| 41 | refresh(); | ||
| 42 | const subscription = AppState.addEventListener("change", (state) => { | ||
| 43 | if (state === "active") refresh(); | ||
| 44 | }); | ||
| 45 | return () => subscription.remove(); | ||
| 46 | }, [refresh]); | ||
| 47 | const request = useCallback( | ||
| 48 | () => void requestRecordingPermissionsAsync().then(apply, refresh), | ||
| 49 | [apply, refresh], | ||
| 50 | ); | ||
| 51 | return { permission, request }; | ||
| 52 | } | ||
| 53 | |||
| 54 | const KINDS: Record< | ||
| 55 | MicrophoneKind, | ||
| 56 | { | ||
| 57 | /** Shown under a remembered device's name. */ | ||
| 58 | readonly label: string; | ||
| 59 | readonly row: string; | ||
| 60 | readonly rowDescription: string; | ||
| 61 | readonly icon: AppSymbolName; | ||
| 62 | } | ||
| 63 | > = { | ||
| 64 | wired: { | ||
| 65 | label: "Wired", | ||
| 66 | row: "Other wired", | ||
| 67 | rowDescription: "Headsets and USB microphones not listed", | ||
| 68 | icon: "cable.connector", | ||
| 69 | }, | ||
| 70 | bluetooth: { | ||
| 71 | label: "Bluetooth", | ||
| 72 | row: "Other Bluetooth", | ||
| 73 | rowDescription: "AirPods, headsets, and cars not listed", | ||
| 74 | icon: "headphones", | ||
| 75 | }, | ||
| 76 | builtIn: { | ||
| 77 | label: "Built-in", | ||
| 78 | row: "Built-in microphone", | ||
| 79 | rowDescription: "The microphone on this device", | ||
| 80 | icon: "iphone", | ||
| 81 | }, | ||
| 82 | carPlay: { | ||
| 83 | label: "CarPlay", | ||
| 84 | row: "CarPlay", | ||
| 85 | rowDescription: "The car’s microphone", | ||
| 86 | icon: "car", | ||
| 87 | }, | ||
| 88 | }; | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Microphone kinds and remembered devices, in the order voice input prefers | ||
| 92 | * them. iOS only. A device joins the list the first time it is connected | ||
| 93 | * during dictation; until then its kind's row ranks it. | ||
| 94 | */ | ||
| 95 | export function SettingsMicrophoneRouteScreen() { | ||
| 96 | const insets = useSafeAreaInsets(); | ||
| 97 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | ||
| 98 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 99 | const ready = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; | ||
| 100 | const entries = microphoneOrder( | ||
| 101 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined, | ||
| 102 | ); | ||
| 103 | const hasDevices = entries.some(isMicrophoneDevice); | ||
| 104 | const { permission, request: requestPermission } = useMicrophonePermission(); | ||
| 105 | const [editing, setEditing] = useState(false); | ||
| 106 | const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>( | ||
| 107 | null, | ||
| 108 | ); | ||
| 109 | const [rowHeight, setRowHeight] = useState(0); | ||
| 110 | // Each drop remounts the rows so the new order and the cleared offsets land in one frame. | ||
| 111 | const [drops, setDrops] = useState(0); | ||
| 112 | |||
| 113 | // Transforms apply to the stored order, so a device recorded meanwhile is kept. | ||
| 114 | const move = (key: string, steps: number) => | ||
| 115 | savePreferences({ | ||
| 116 | transform: (current) => { | ||
| 117 | const next = [...microphoneOrder(current.microphones)]; | ||
| 118 | const from = next.findIndex((entry) => microphoneEntryKey(entry) === key); | ||
| 119 | const to = Math.min(next.length - 1, Math.max(0, from + steps)); | ||
| 120 | if (from === -1 || from === to) return {}; | ||
| 121 | const [moved] = next.splice(from, 1); | ||
| 122 | next.splice(to, 0, moved!); | ||
| 123 | return { microphones: next }; | ||
| 124 | }, | ||
| 125 | }); | ||
| 126 | const forget = (key: string) => | ||
| 127 | savePreferences({ | ||
| 128 | transform: (current) => ({ | ||
| 129 | microphones: microphoneOrder(current.microphones).filter( | ||
| 130 | (entry) => microphoneEntryKey(entry) !== key, | ||
| 131 | ), | ||
| 132 | }), | ||
| 133 | }); | ||
| 134 | // A lifted row takes a neighbour's slot once it has moved past half of that row. | ||
| 135 | const dropSteps = (from: number, translation: number) => | ||
| 136 | rowHeight === 0 | ||
| 137 | ? 0 | ||
| 138 | : Math.min(entries.length - 1, Math.max(0, from + Math.round(translation / rowHeight))) - | ||
| 139 | from; | ||
| 140 | |||
| 141 | const dragFrom = | ||
| 142 | drag === null ? -1 : entries.findIndex((entry) => microphoneEntryKey(entry) === drag.key); | ||
| 143 | const dragTo = drag === null ? -1 : dragFrom + dropSteps(dragFrom, drag.translation); | ||
| 144 | |||
| 145 | return ( | ||
| 146 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 147 | <ScrollView | ||
| 148 | contentInsetAdjustmentBehavior="automatic" | ||
| 149 | showsVerticalScrollIndicator={false} | ||
| 150 | className="flex-1" | ||
| 151 | contentContainerClassName="gap-3 px-5 pt-4" | ||
| 152 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 153 | > | ||
| 154 | {permission === "ask" || permission === "denied" ? ( | ||
| 155 | <> | ||
| 156 | <SettingsSection title="Microphone access"> | ||
| 157 | <SettingsActionRow | ||
| 158 | icon="mic" | ||
| 159 | label={permission === "ask" ? "Allow microphone access" : "Open Settings"} | ||
| 160 | onPress={ | ||
| 161 | permission === "ask" ? requestPermission : () => void Linking.openSettings() | ||
| 162 | } | ||
| 163 | /> | ||
| 164 | </SettingsSection> | ||
| 165 | <Text className="px-2 text-sm text-foreground-muted"> | ||
| 166 | {permission === "ask" | ||
| 167 | ? "Voice input needs microphone access before it can record or list your microphones." | ||
| 168 | : "Microphone access is off for T3 Code. Turn it on in Settings to use voice input."} | ||
| 169 | </Text> | ||
| 170 | </> | ||
| 171 | ) : null} | ||
| 172 | <SettingsSection | ||
| 173 | title="Preferred order" | ||
| 174 | trailing={ | ||
| 175 | hasDevices ? ( | ||
| 176 | <Pressable | ||
| 177 | accessibilityRole="button" | ||
| 178 | onPress={() => setEditing((value) => !value)} | ||
| 179 | className="px-2 py-1 active:opacity-70" | ||
| 180 | > | ||
| 181 | <Text className="text-sm font-t3-medium text-foreground"> | ||
| 182 | {editing ? "Done" : "Edit"} | ||
| 183 | </Text> | ||
| 184 | </Pressable> | ||
| 185 | ) : undefined | ||
| 186 | } | ||
| 187 | > | ||
| 188 | {entries.map((entry, index) => { | ||
| 189 | const key = microphoneEntryKey(entry); | ||
| 190 | const shift = | ||
| 191 | dragFrom === -1 || index === dragFrom | ||
| 192 | ? 0 | ||
| 193 | : dragFrom < dragTo && index > dragFrom && index <= dragTo | ||
| 194 | ? -rowHeight | ||
| 195 | : dragFrom > dragTo && index < dragFrom && index >= dragTo | ||
| 196 | ? rowHeight | ||
| 197 | : 0; | ||
| 198 | return ( | ||
| 199 | <MicrophoneRow | ||
| 200 | key={`${key}:${drops}`} | ||
| 201 | entry={entry} | ||
| 202 | position={index + 1} | ||
| 203 | count={entries.length} | ||
| 204 | reorderable={ready} | ||
| 205 | editing={editing && hasDevices} | ||
| 206 | offset={index === dragFrom ? (drag?.translation ?? 0) : shift} | ||
| 207 | lifted={index === dragFrom} | ||
| 208 | onHeight={setRowHeight} | ||
| 209 | onDragStart={() => setDrag({ key, translation: 0 })} | ||
| 210 | onDragMove={(translation) => setDrag({ key, translation })} | ||
| 211 | onDragEnd={(translation, cancelled) => { | ||
| 212 | setDrag(null); | ||
| 213 | setDrops((count) => count + 1); | ||
| 214 | if (!cancelled) move(key, dropSteps(index, translation)); | ||
| 215 | }} | ||
| 216 | onStep={(direction) => move(key, direction === "up" ? -1 : 1)} | ||
| 217 | onForget={isMicrophoneDevice(entry) ? () => forget(key) : undefined} | ||
| 218 | /> | ||
| 219 | ); | ||
| 220 | })} | ||
| 221 | </SettingsSection> | ||
| 222 | <Text className="px-2 text-sm text-foreground-muted"> | ||
| 223 | Voice input records from the first connected microphone in this list. Wired and Bluetooth | ||
| 224 | devices are added by name after you use voice input with them connected; until then, the | ||
| 225 | Other row ranks them. | ||
| 226 | </Text> | ||
| 227 | </ScrollView> | ||
| 228 | </View> | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | |||
| 232 | function MicrophoneRow(props: { | ||
| 233 | readonly entry: MicrophoneEntry; | ||
| 234 | readonly position: number; | ||
| 235 | readonly count: number; | ||
| 236 | readonly reorderable: boolean; | ||
| 237 | readonly editing: boolean; | ||
| 238 | readonly offset: number; | ||
| 239 | readonly lifted: boolean; | ||
| 240 | readonly onHeight: (height: number) => void; | ||
| 241 | readonly onDragStart: () => void; | ||
| 242 | readonly onDragMove: (translation: number) => void; | ||
| 243 | readonly onDragEnd: (translation: number, cancelled: boolean) => void; | ||
| 244 | readonly onStep: (direction: "up" | "down") => void; | ||
| 245 | readonly onForget: (() => void) | undefined; | ||
| 246 | }) { | ||
| 247 | const { lifted, offset, entry } = props; | ||
| 248 | const kind = KINDS[entry.kind]; | ||
| 249 | const title = isMicrophoneDevice(entry) ? entry.name : kind.row; | ||
| 250 | const style = useAnimatedStyle(() => ({ | ||
| 251 | transform: [ | ||
| 252 | { | ||
| 253 | translateY: lifted | ||
| 254 | ? offset | ||
| 255 | : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }), | ||
| 256 | }, | ||
| 257 | ], | ||
| 258 | zIndex: lifted ? 1 : 0, | ||
| 259 | })); | ||
| 260 | return ( | ||
| 261 | <Reanimated.View | ||
| 262 | style={style} | ||
| 263 | onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)} | ||
| 264 | className={cn( | ||
| 265 | "flex-row items-center gap-4 pl-4", | ||
| 266 | props.position > 1 && !lifted && "border-t border-border-subtle", | ||
| 267 | lifted && "bg-grouped-card shadow-md", | ||
| 268 | !props.reorderable && "pr-4", | ||
| 269 | )} | ||
| 270 | > | ||
| 271 | {props.editing && props.onForget ? ( | ||
| 272 | <Pressable | ||
| 273 | accessibilityRole="button" | ||
| 274 | accessibilityLabel={`Forget ${title}`} | ||
| 275 | hitSlop={8} | ||
| 276 | onPress={props.onForget} | ||
| 277 | className="active:opacity-70" | ||
| 278 | > | ||
| 279 | <SymbolView | ||
| 280 | name="xmark.circle.fill" | ||
| 281 | size={REMOVE_SIZE} | ||
| 282 | tintColorClassName="accent-danger-foreground" | ||
| 283 | type="monochrome" | ||
| 284 | /> | ||
| 285 | </Pressable> | ||
| 286 | ) : props.editing ? ( | ||
| 287 | <View style={{ width: REMOVE_SIZE }} /> | ||
| 288 | ) : null} | ||
| 289 | <View | ||
| 290 | accessible | ||
| 291 | accessibilityLabel={`${title}, ${kind.label}, ${props.position} of ${props.count}`} | ||
| 292 | className="min-w-0 flex-1 flex-row items-center gap-4 py-4" | ||
| 293 | > | ||
| 294 | <SymbolView | ||
| 295 | name={kind.icon} | ||
| 296 | size={22} | ||
| 297 | tintColorClassName="accent-icon" | ||
| 298 | type="monochrome" | ||
| 299 | weight="regular" | ||
| 300 | /> | ||
| 301 | <View className="min-w-0 flex-1 gap-0.5"> | ||
| 302 | <Text numberOfLines={1} className="text-lg text-foreground"> | ||
| 303 | {title} | ||
| 304 | </Text> | ||
| 305 | <Text numberOfLines={1} className="text-sm text-foreground-muted"> | ||
| 306 | {isMicrophoneDevice(entry) ? kind.label : kind.rowDescription} | ||
| 307 | </Text> | ||
| 308 | </View> | ||
| 309 | </View> | ||
| 310 | {props.reorderable ? ( | ||
| 311 | <SettingsDragHandle | ||
| 312 | title={title} | ||
| 313 | canMoveUp={props.position > 1} | ||
| 314 | canMoveDown={props.position < props.count} | ||
| 315 | onStart={props.onDragStart} | ||
| 316 | onMove={props.onDragMove} | ||
| 317 | onEnd={props.onDragEnd} | ||
| 318 | onStep={props.onStep} | ||
| 319 | /> | ||
| 320 | ) : null} | ||
| 321 | </Reanimated.View> | ||
| 322 | ); | ||
| 323 | } | ||
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+4-1| ... | @@ -154,7 +154,10 @@ function SettingsIndexSections() { | ... | @@ -154,7 +154,10 @@ function SettingsIndexSections() { |
| 154 | <SettingsSection title="Interface"> | 154 | <SettingsSection title="Interface"> |
| 155 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | 155 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> |
| 156 | {Platform.OS === "ios" ? ( | 156 | {Platform.OS === "ios" ? ( |
| 157 | <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" /> | 157 | <> |
| 158 | <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" /> | ||
| 159 | <SettingsRow icon="mic" label="Microphone" target="SettingsMicrophone" /> | ||
| 160 | </> | ||
| 158 | ) : null} | 161 | ) : null} |
| 159 | </SettingsSection> | 162 | </SettingsSection> |
| 160 | 163 |
apps/mobile/src/features/settings/components/SettingsDragHandle.tsx created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { useEffect, useMemo, useRef } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 4 | |||
| 5 | import { SymbolView } from "../../../components/AppSymbol"; | ||
| 6 | |||
| 7 | /** Pan recognition wins over the settings scroll view only inside the handle. */ | ||
| 8 | export function SettingsDragHandle(props: { | ||
| 9 | readonly title: string; | ||
| 10 | readonly canMoveUp: boolean; | ||
| 11 | readonly canMoveDown: boolean; | ||
| 12 | readonly onStart: () => void; | ||
| 13 | readonly onMove: (translation: number) => void; | ||
| 14 | readonly onEnd: (translation: number, cancelled: boolean) => void; | ||
| 15 | readonly onStep: (direction: "up" | "down") => void; | ||
| 16 | }) { | ||
| 17 | const latest = useRef(props); | ||
| 18 | useEffect(() => { | ||
| 19 | latest.current = props; | ||
| 20 | }); | ||
| 21 | const translation = useRef(0); | ||
| 22 | const gesture = useMemo( | ||
| 23 | () => | ||
| 24 | Gesture.Pan() | ||
| 25 | .minDistance(0) | ||
| 26 | .shouldCancelWhenOutside(false) | ||
| 27 | .runOnJS(true) | ||
| 28 | .onStart(() => { | ||
| 29 | translation.current = 0; | ||
| 30 | latest.current.onStart(); | ||
| 31 | }) | ||
| 32 | .onUpdate((event) => { | ||
| 33 | translation.current = event.translationY; | ||
| 34 | latest.current.onMove(event.translationY); | ||
| 35 | }) | ||
| 36 | .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)), | ||
| 37 | [], | ||
| 38 | ); | ||
| 39 | return ( | ||
| 40 | <GestureDetector gesture={gesture}> | ||
| 41 | <View | ||
| 42 | collapsable={false} | ||
| 43 | accessible | ||
| 44 | accessibilityRole="adjustable" | ||
| 45 | accessibilityLabel={`Reorder ${props.title}`} | ||
| 46 | accessibilityActions={[ | ||
| 47 | ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []), | ||
| 48 | ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []), | ||
| 49 | ]} | ||
| 50 | onAccessibilityAction={({ nativeEvent }) => { | ||
| 51 | if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); | ||
| 52 | if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); | ||
| 53 | }} | ||
| 54 | style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }} | ||
| 55 | > | ||
| 56 | <SymbolView | ||
| 57 | name="line.3.horizontal" | ||
| 58 | size={20} | ||
| 59 | tintColorClassName="accent-foreground-muted" | ||
| 60 | /> | ||
| 61 | </View> | ||
| 62 | </GestureDetector> | ||
| 63 | ); | ||
| 64 | } | ||
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | @@ -13,6 +13,7 @@ export type SettingsSheetTarget = | ... | @@ -13,6 +13,7 @@ export type SettingsSheetTarget = |
| 13 | | "SettingsEnvironmentMaintenance" | 13 | | "SettingsEnvironmentMaintenance" |
| 14 | | "SettingsProviderAccounts" | 14 | | "SettingsProviderAccounts" |
| 15 | | "SettingsKeyboard" | 15 | | "SettingsKeyboard" |
| 16 | | "SettingsMicrophone" | ||
| 16 | | "SettingsFollowUp" | 17 | | "SettingsFollowUp" |
| 17 | | "SettingsScheduledTasks" | 18 | | "SettingsScheduledTasks" |
| 18 | | "SettingsProjectGrouping" | 19 | | "SettingsProjectGrouping" |
apps/mobile/src/features/voice-input/VoiceInputProvider.tsx+27| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RegistryContext, useAtomMount } from "@effect/atom-react"; | ||
| 1 | import { | 2 | import { |
| 2 | AudioModule, | 3 | AudioModule, |
| 3 | RecordingPresets, | 4 | RecordingPresets, |
| ... | @@ -20,9 +21,16 @@ import { | ... | @@ -20,9 +21,16 @@ import { |
| 20 | type ReactNode, | 21 | type ReactNode, |
| 21 | } from "react"; | 22 | } from "react"; |
| 22 | import { AppState, Platform } from "react-native"; | 23 | import { AppState, Platform } from "react-native"; |
| 24 | import { AsyncResult } from "effect/reactivity"; | ||
| 23 | import { useSharedValue } from "react-native-reanimated"; | 25 | import { useSharedValue } from "react-native-reanimated"; |
| 24 | 26 | ||
| 27 | import { | ||
| 28 | microphoneOrder, | ||
| 29 | preferredMicrophoneInput, | ||
| 30 | rememberMicrophones, | ||
| 31 | } from "../../lib/microphonePriority"; | ||
| 25 | import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; | 32 | import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; |
| 33 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 26 | import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; | 34 | import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; |
| 27 | import { | 35 | import { |
| 28 | VOICE_RECORDING_LIMIT_SECONDS, | 36 | VOICE_RECORDING_LIMIT_SECONDS, |
| ... | @@ -91,6 +99,10 @@ export function useGlobalVoiceInput() { | ... | @@ -91,6 +99,10 @@ export function useGlobalVoiceInput() { |
| 91 | } | 99 | } |
| 92 | 100 | ||
| 93 | function useVoiceInputRuntime() { | 101 | function useVoiceInputRuntime() { |
| 102 | const registry = use(RegistryContext); | ||
| 103 | // Dictation reads and records microphones without subscribing the provider to preferences. | ||
| 104 | useAtomMount(mobilePreferencesAtom); | ||
| 105 | useAtomMount(updateMobilePreferencesAtom); | ||
| 94 | const [{ state, ownerKey, label }, setState] = useState({ | 106 | const [{ state, ownerKey, label }, setState] = useState({ |
| 95 | state: INITIAL_STATE, | 107 | state: INITIAL_STATE, |
| 96 | ownerKey: null as string | null, | 108 | ownerKey: null as string | null, |
| ... | @@ -126,6 +138,21 @@ function useVoiceInputRuntime() { | ... | @@ -126,6 +138,21 @@ function useVoiceInputRuntime() { |
| 126 | url: status.url, | 138 | url: status.url, |
| 127 | }); | 139 | }); |
| 128 | }, | 140 | }, |
| 141 | // Read when recording starts so preference changes do not re-render every composer. | ||
| 142 | selectInput: (inputs) => { | ||
| 143 | const preferences = registry.get(mobilePreferencesAtom); | ||
| 144 | if (!AsyncResult.isSuccess(preferences)) return null; | ||
| 145 | const known = microphoneOrder(preferences.value.microphones); | ||
| 146 | const order = rememberMicrophones(known, inputs); | ||
| 147 | if (order !== known) { | ||
| 148 | registry.set(updateMobilePreferencesAtom, { | ||
| 149 | transform: (current) => ({ | ||
| 150 | microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs), | ||
| 151 | }), | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | return preferredMicrophoneInput(inputs, order); | ||
| 155 | }, | ||
| 129 | }); | 156 | }); |
| 130 | recorderRef.current = recorder; | 157 | recorderRef.current = recorder; |
| 131 | sessionRef.current = new VoiceInputSession({ | 158 | sessionRef.current = new VoiceInputSession({ |
apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts+33-2| ... | @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; | ... | @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { VoiceInputController } from "@t3tools/client-runtime/voice-input"; | 3 | import { VoiceInputController } from "@t3tools/client-runtime/voice-input"; |
| 4 | 4 | ||
| 5 | import { createLazyVoiceRecorder } from "./lazyVoiceRecorder"; | 5 | import { createLazyVoiceRecorder, type VoiceRecordingInput } from "./lazyVoiceRecorder"; |
| 6 | 6 | ||
| 7 | type Status = { readonly isFinished: boolean }; | 7 | type Status = { readonly isFinished: boolean }; |
| 8 | type State = { readonly isRecording: boolean }; | 8 | type State = { readonly isRecording: boolean }; |
| ... | @@ -30,12 +30,19 @@ function createNativeRecorder(uri: string) { | ... | @@ -30,12 +30,19 @@ function createNativeRecorder(uri: string) { |
| 30 | listeners.add(listener); | 30 | listeners.add(listener); |
| 31 | return { remove: () => listeners.delete(listener) }; | 31 | return { remove: () => listeners.delete(listener) }; |
| 32 | }), | 32 | }), |
| 33 | getAvailableInputs: vi.fn((): ReadonlyArray<VoiceRecordingInput> => [ | ||
| 34 | { uid: "car", name: "CarPlay", type: "CarAudio" }, | ||
| 35 | { uid: "built-in", name: "iPhone Microphone", type: "MicrophoneBuiltIn" }, | ||
| 36 | ]), | ||
| 37 | setInput: vi.fn((_uid: string) => undefined), | ||
| 33 | release: vi.fn(), | 38 | release: vi.fn(), |
| 34 | emit: (status: Status) => listeners.forEach((listener) => listener(status)), | 39 | emit: (status: Status) => listeners.forEach((listener) => listener(status)), |
| 35 | }; | 40 | }; |
| 36 | } | 41 | } |
| 37 | 42 | ||
| 38 | function createHarness() { | 43 | function createHarness( |
| 44 | selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null, | ||
| 45 | ) { | ||
| 39 | const created: Array<ReturnType<typeof createNativeRecorder>> = []; | 46 | const created: Array<ReturnType<typeof createNativeRecorder>> = []; |
| 40 | const statuses: Status[] = []; | 47 | const statuses: Status[] = []; |
| 41 | const recorder = createLazyVoiceRecorder({ | 48 | const recorder = createLazyVoiceRecorder({ |
| ... | @@ -45,6 +52,7 @@ function createHarness() { | ... | @@ -45,6 +52,7 @@ function createHarness() { |
| 45 | return native; | 52 | return native; |
| 46 | }, | 53 | }, |
| 47 | onStatus: (status) => statuses.push(status), | 54 | onStatus: (status) => statuses.push(status), |
| 55 | ...(selectInput ? { selectInput } : {}), | ||
| 48 | }); | 56 | }); |
| 49 | return { recorder, created, statuses }; | 57 | return { recorder, created, statuses }; |
| 50 | } | 58 | } |
| ... | @@ -79,6 +87,29 @@ describe("createLazyVoiceRecorder", () => { | ... | @@ -79,6 +87,29 @@ describe("createLazyVoiceRecorder", () => { |
| 79 | expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2); | 87 | expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2); |
| 80 | }); | 88 | }); |
| 81 | 89 | ||
| 90 | it("selects the preferred input after preparing, when the session lists inputs", async () => { | ||
| 91 | const { recorder, created } = createHarness( | ||
| 92 | (inputs) => inputs.find((input) => input.type === "MicrophoneBuiltIn") ?? null, | ||
| 93 | ); | ||
| 94 | |||
| 95 | await recorder.prepareToRecordAsync(); | ||
| 96 | |||
| 97 | expect(created[0]!.setInput).toHaveBeenCalledWith("built-in"); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("keeps the system input when none is preferred or the preferred one disconnects", async () => { | ||
| 101 | const unpreferred = createHarness(() => null); | ||
| 102 | await unpreferred.recorder.prepareToRecordAsync(); | ||
| 103 | expect(unpreferred.created[0]!.setInput).not.toHaveBeenCalled(); | ||
| 104 | |||
| 105 | const disconnected = createHarness((inputs) => inputs[1] ?? null); | ||
| 106 | await disconnected.recorder.prepareToRecordAsync(); | ||
| 107 | disconnected.created[0]!.setInput.mockImplementationOnce(() => { | ||
| 108 | throw new Error("Preferred input not found"); | ||
| 109 | }); | ||
| 110 | await expect(disconnected.recorder.prepareToRecordAsync()).resolves.toBeUndefined(); | ||
| 111 | }); | ||
| 112 | |||
| 82 | it("forwards status events until the recorder is released", async () => { | 113 | it("forwards status events until the recorder is released", async () => { |
| 83 | const { recorder, created, statuses } = createHarness(); | 114 | const { recorder, created, statuses } = createHarness(); |
| 84 | await recorder.prepareToRecordAsync(); | 115 | await recorder.prepareToRecordAsync(); |
apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts+22-1| ... | @@ -2,6 +2,12 @@ import type { VoiceRecorder } from "@t3tools/client-runtime/voice-input"; | ... | @@ -2,6 +2,12 @@ import type { VoiceRecorder } from "@t3tools/client-runtime/voice-input"; |
| 2 | 2 | ||
| 3 | type Subscription = { remove(): void }; | 3 | type Subscription = { remove(): void }; |
| 4 | 4 | ||
| 5 | export interface VoiceRecordingInput { | ||
| 6 | readonly uid: string; | ||
| 7 | readonly name: string; | ||
| 8 | readonly type: string; | ||
| 9 | } | ||
| 10 | |||
| 5 | // The subset of expo-audio's native `AudioRecorder` the voice input hook uses. | 11 | // The subset of expo-audio's native `AudioRecorder` the voice input hook uses. |
| 6 | export interface NativeVoiceRecorder<TStatus, TRecorderState> { | 12 | export interface NativeVoiceRecorder<TStatus, TRecorderState> { |
| 7 | readonly uri: string | null; | 13 | readonly uri: string | null; |
| ... | @@ -13,6 +19,8 @@ export interface NativeVoiceRecorder<TStatus, TRecorderState> { | ... | @@ -13,6 +19,8 @@ export interface NativeVoiceRecorder<TStatus, TRecorderState> { |
| 13 | eventName: "recordingStatusUpdate", | 19 | eventName: "recordingStatusUpdate", |
| 14 | listener: (status: TStatus) => void, | 20 | listener: (status: TStatus) => void, |
| 15 | ): Subscription; | 21 | ): Subscription; |
| 22 | getAvailableInputs(): ReadonlyArray<VoiceRecordingInput>; | ||
| 23 | setInput(uid: string): void; | ||
| 16 | release(): void; | 24 | release(): void; |
| 17 | } | 25 | } |
| 18 | 26 | ||
| ... | @@ -34,6 +42,8 @@ export interface LazyVoiceRecorder<TRecorderState> extends VoiceRecorder { | ... | @@ -34,6 +42,8 @@ export interface LazyVoiceRecorder<TRecorderState> extends VoiceRecorder { |
| 34 | export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: { | 42 | export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: { |
| 35 | readonly create: () => NativeVoiceRecorder<TStatus, TRecorderState>; | 43 | readonly create: () => NativeVoiceRecorder<TStatus, TRecorderState>; |
| 36 | readonly onStatus: (status: TStatus) => void; | 44 | readonly onStatus: (status: TStatus) => void; |
| 45 | /** Picks the input to record from once the session lists them, or null to keep the system's. */ | ||
| 46 | readonly selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null; | ||
| 37 | }): LazyVoiceRecorder<TRecorderState> { | 47 | }): LazyVoiceRecorder<TRecorderState> { |
| 38 | let recorder: NativeVoiceRecorder<TStatus, TRecorderState> | null = null; | 48 | let recorder: NativeVoiceRecorder<TStatus, TRecorderState> | null = null; |
| 39 | let subscription: Subscription | null = null; | 49 | let subscription: Subscription | null = null; |
| ... | @@ -57,7 +67,18 @@ export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: { | ... | @@ -57,7 +67,18 @@ export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: { |
| 57 | recorder = input.create(); | 67 | recorder = input.create(); |
| 58 | subscription = recorder.addListener("recordingStatusUpdate", input.onStatus); | 68 | subscription = recorder.addListener("recordingStatusUpdate", input.onStatus); |
| 59 | } | 69 | } |
| 60 | return track(recorder.prepareToRecordAsync()); | 70 | const prepared = recorder; |
| 71 | return track( | ||
| 72 | prepared.prepareToRecordAsync().then(() => { | ||
| 73 | if (!input.selectInput) return; | ||
| 74 | try { | ||
| 75 | const selected = input.selectInput(prepared.getAvailableInputs()); | ||
| 76 | if (selected) prepared.setInput(selected.uid); | ||
| 77 | } catch { | ||
| 78 | // The input can disconnect in between. The system's route still records. | ||
| 79 | } | ||
| 80 | }), | ||
| 81 | ); | ||
| 61 | }, | 82 | }, |
| 62 | record(options) { | 83 | record(options) { |
| 63 | if (!recorder) throw new Error("Voice recorder is not prepared."); | 84 | if (!recorder) throw new Error("Voice recorder is not prepared."); |
apps/mobile/src/lib/microphonePriority.test.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type MicrophoneEntry, | ||
| 5 | microphoneEntryKey, | ||
| 6 | microphoneOrder, | ||
| 7 | preferredMicrophoneInput, | ||
| 8 | rememberMicrophones, | ||
| 9 | } from "./microphonePriority"; | ||
| 10 | |||
| 11 | const iPhone = { uid: "Built-In Microphone", name: "iPhone Microphone", type: "MicrophoneBuiltIn" }; | ||
| 12 | const airPods = { uid: "airpods", name: "Julius’s AirPods Pro", type: "BluetoothHFP" }; | ||
| 13 | const bose = { uid: "bose", name: "Julius’s Bose QC", type: "BluetoothHFP" }; | ||
| 14 | const civic = { uid: "civic", name: "Honda Civic", type: "BluetoothHFP" }; | ||
| 15 | const carPlay = { uid: "carplay", name: "CarPlay", type: "CarAudio" }; | ||
| 16 | const usb = { uid: "usb", name: "Shure MV7", type: "USBAudio" }; | ||
| 17 | |||
| 18 | const keys = (order: ReadonlyArray<MicrophoneEntry>) => order.map(microphoneEntryKey); | ||
| 19 | |||
| 20 | describe("microphoneOrder", () => { | ||
| 21 | it("lists every kind once, CarPlay below the built-in microphone by default", () => { | ||
| 22 | expect(keys(microphoneOrder())).toEqual([ | ||
| 23 | "kind:wired", | ||
| 24 | "kind:bluetooth", | ||
| 25 | "kind:builtIn", | ||
| 26 | "kind:carPlay", | ||
| 27 | ]); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("keeps a saved order and files missing kinds after their devices", () => { | ||
| 31 | const saved: MicrophoneEntry[] = [ | ||
| 32 | { kind: "builtIn" }, | ||
| 33 | { kind: "bluetooth", uid: "airpods", name: "AirPods" }, | ||
| 34 | { kind: "builtIn" }, | ||
| 35 | ]; | ||
| 36 | expect(keys(microphoneOrder(saved))).toEqual([ | ||
| 37 | "kind:wired", | ||
| 38 | "kind:builtIn", | ||
| 39 | "airpods", | ||
| 40 | "kind:bluetooth", | ||
| 41 | "kind:carPlay", | ||
| 42 | ]); | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | describe("rememberMicrophones", () => { | ||
| 47 | it("lists a new device just above its kind and leaves built-in and CarPlay as kinds", () => { | ||
| 48 | const order = microphoneOrder(); | ||
| 49 | expect(keys(rememberMicrophones(order, [iPhone, carPlay, airPods]))).toEqual([ | ||
| 50 | "kind:wired", | ||
| 51 | "airpods", | ||
| 52 | "kind:bluetooth", | ||
| 53 | "kind:builtIn", | ||
| 54 | "kind:carPlay", | ||
| 55 | ]); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("refreshes renamed devices and returns the same order when nothing changed", () => { | ||
| 59 | const order = rememberMicrophones(microphoneOrder(), [airPods]); | ||
| 60 | expect(rememberMicrophones(order, [airPods, iPhone])).toBe(order); | ||
| 61 | |||
| 62 | const renamed = rememberMicrophones(order, [{ ...airPods, name: "AirPods Max" }]); | ||
| 63 | expect(renamed.find((entry) => microphoneEntryKey(entry) === "airpods")).toEqual({ | ||
| 64 | kind: "bluetooth", | ||
| 65 | uid: "airpods", | ||
| 66 | name: "AirPods Max", | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | describe("preferredMicrophoneInput", () => { | ||
| 72 | // Built-in, Bose, Wired, other Bluetooth, AirPods, CarPlay. | ||
| 73 | const order: MicrophoneEntry[] = [ | ||
| 74 | { kind: "builtIn" }, | ||
| 75 | { kind: "bluetooth", uid: "bose", name: bose.name }, | ||
| 76 | { kind: "wired" }, | ||
| 77 | { kind: "bluetooth" }, | ||
| 78 | { kind: "bluetooth", uid: "airpods", name: airPods.name }, | ||
| 79 | { kind: "carPlay" }, | ||
| 80 | ]; | ||
| 81 | |||
| 82 | it("ranks unlisted devices of a kind at that kind, ahead of a device listed below it", () => { | ||
| 83 | expect(preferredMicrophoneInput([airPods, civic], order)).toBe(civic); | ||
| 84 | expect(preferredMicrophoneInput([airPods, bose], order)).toBe(bose); | ||
| 85 | expect(preferredMicrophoneInput([airPods, usb], order)).toBe(usb); | ||
| 86 | expect(preferredMicrophoneInput([airPods, carPlay], order)).toBe(airPods); | ||
| 87 | expect(preferredMicrophoneInput([airPods, iPhone], order)).toBe(iPhone); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("leaves inputs of unknown kinds to iOS", () => { | ||
| 91 | expect(preferredMicrophoneInput([{ uid: "x", name: "x", type: "Virtual" }], order)).toBeNull(); | ||
| 92 | }); | ||
| 93 | }); | ||
apps/mobile/src/lib/microphonePriority.ts created+132| ... | @@ -0,0 +1,132 @@ | ||
| 1 | /** | ||
| 2 | * The order voice input picks a microphone in. An entry is either a kind, | ||
| 3 | * which covers every connected microphone of that kind not listed by name, or | ||
| 4 | * a device remembered the first time dictation saw it connected. iOS lists | ||
| 5 | * only connected inputs, and only while a recording session is set up, so a | ||
| 6 | * device cannot be listed before then. | ||
| 7 | */ | ||
| 8 | export const MICROPHONE_KINDS = ["wired", "bluetooth", "builtIn", "carPlay"] as const; | ||
| 9 | |||
| 10 | export type MicrophoneKind = (typeof MICROPHONE_KINDS)[number]; | ||
| 11 | |||
| 12 | export interface MicrophoneDevice { | ||
| 13 | readonly kind: MicrophoneKind; | ||
| 14 | /** iOS's port identifier, stable across reconnects of the same device. */ | ||
| 15 | readonly uid: string; | ||
| 16 | readonly name: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | export type MicrophoneEntry = { readonly kind: MicrophoneKind } | MicrophoneDevice; | ||
| 20 | |||
| 21 | interface MicrophoneInput { | ||
| 22 | readonly uid: string; | ||
| 23 | readonly name: string; | ||
| 24 | readonly type: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | // Raw values of AVAudioSession.Port. A car without CarPlay connects as a | ||
| 28 | // Bluetooth headset, so it is a Bluetooth device. | ||
| 29 | const KIND_BY_PORT_TYPE: Readonly<Record<string, MicrophoneKind>> = { | ||
| 30 | MicrophoneWired: "wired", | ||
| 31 | USBAudio: "wired", | ||
| 32 | LineIn: "wired", | ||
| 33 | BluetoothHFP: "bluetooth", | ||
| 34 | BluetoothLE: "bluetooth", | ||
| 35 | MicrophoneBuiltIn: "builtIn", | ||
| 36 | CarAudio: "carPlay", | ||
| 37 | }; | ||
| 38 | |||
| 39 | // Built-in and CarPlay are listed only as kinds: a phone has one built-in | ||
| 40 | // microphone, and CarPlay is the car. | ||
| 41 | const DEVICE_KINDS: ReadonlySet<MicrophoneKind> = new Set(["wired", "bluetooth"]); | ||
| 42 | |||
| 43 | const rank = (kind: MicrophoneKind) => MICROPHONE_KINDS.indexOf(kind); | ||
| 44 | |||
| 45 | export function isMicrophoneKind(value: unknown): value is MicrophoneKind { | ||
| 46 | return (MICROPHONE_KINDS as ReadonlyArray<unknown>).includes(value); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function isMicrophoneDevice(entry: MicrophoneEntry): entry is MicrophoneDevice { | ||
| 50 | return "uid" in entry; | ||
| 51 | } | ||
| 52 | |||
| 53 | export function microphoneEntryKey(entry: MicrophoneEntry): string { | ||
| 54 | return isMicrophoneDevice(entry) ? entry.uid : `kind:${entry.kind}`; | ||
| 55 | } | ||
| 56 | |||
| 57 | /** | ||
| 58 | * The saved order with each kind listed once. A missing kind goes after the | ||
| 59 | * devices of that kind, else above the first kind ranked below it by default, | ||
| 60 | * which puts CarPlay below the built-in microphone. | ||
| 61 | */ | ||
| 62 | export function microphoneOrder( | ||
| 63 | saved: ReadonlyArray<MicrophoneEntry> = [], | ||
| 64 | ): ReadonlyArray<MicrophoneEntry> { | ||
| 65 | const order: MicrophoneEntry[] = []; | ||
| 66 | const keys = new Set<string>(); | ||
| 67 | for (const entry of saved) { | ||
| 68 | const row = | ||
| 69 | isMicrophoneDevice(entry) && DEVICE_KINDS.has(entry.kind) ? entry : { kind: entry.kind }; | ||
| 70 | const key = microphoneEntryKey(row); | ||
| 71 | if (keys.has(key)) continue; | ||
| 72 | keys.add(key); | ||
| 73 | order.push(row); | ||
| 74 | } | ||
| 75 | for (const kind of MICROPHONE_KINDS) { | ||
| 76 | if (keys.has(`kind:${kind}`)) continue; | ||
| 77 | const lastDevice = order.map((entry) => entry.kind).lastIndexOf(kind); | ||
| 78 | const below = order.findIndex( | ||
| 79 | (entry) => !isMicrophoneDevice(entry) && rank(entry.kind) > rank(kind), | ||
| 80 | ); | ||
| 81 | order.splice(lastDevice !== -1 ? lastDevice + 1 : below !== -1 ? below : order.length, 0, { | ||
| 82 | kind, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | return order; | ||
| 86 | } | ||
| 87 | |||
| 88 | /** | ||
| 89 | * Lists connected wired and Bluetooth microphones by name and refreshes | ||
| 90 | * renamed ones. A new device goes just above its kind, keeping the rank it had | ||
| 91 | * while unlisted. Returns `order` itself when nothing changed. | ||
| 92 | */ | ||
| 93 | export function rememberMicrophones( | ||
| 94 | order: ReadonlyArray<MicrophoneEntry>, | ||
| 95 | inputs: ReadonlyArray<MicrophoneInput>, | ||
| 96 | ): ReadonlyArray<MicrophoneEntry> { | ||
| 97 | let next: MicrophoneEntry[] | null = null; | ||
| 98 | for (const input of inputs) { | ||
| 99 | const kind = KIND_BY_PORT_TYPE[input.type]; | ||
| 100 | if (!kind || !DEVICE_KINDS.has(kind)) continue; | ||
| 101 | const current = next ?? order; | ||
| 102 | const index = current.findIndex((entry) => microphoneEntryKey(entry) === input.uid); | ||
| 103 | const existing = current[index]; | ||
| 104 | if (existing && isMicrophoneDevice(existing) && existing.name === input.name) continue; | ||
| 105 | next ??= [...order]; | ||
| 106 | const device = { kind, uid: input.uid, name: input.name }; | ||
| 107 | if (index !== -1) { | ||
| 108 | next[index] = device; | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | const kindRow = next.findIndex((entry) => microphoneEntryKey(entry) === `kind:${kind}`); | ||
| 112 | next.splice(kindRow === -1 ? next.length : kindRow, 0, device); | ||
| 113 | } | ||
| 114 | return next ?? order; | ||
| 115 | } | ||
| 116 | |||
| 117 | /** The connected input of the first entry that has one, or null to leave the route to iOS. */ | ||
| 118 | export function preferredMicrophoneInput<Input extends MicrophoneInput>( | ||
| 119 | inputs: ReadonlyArray<Input>, | ||
| 120 | order: ReadonlyArray<MicrophoneEntry>, | ||
| 121 | ): Input | null { | ||
| 122 | const listed = new Set(order.filter(isMicrophoneDevice).map((device) => device.uid)); | ||
| 123 | for (const entry of order) { | ||
| 124 | const input = inputs.find((candidate) => | ||
| 125 | isMicrophoneDevice(entry) | ||
| 126 | ? candidate.uid === entry.uid | ||
| 127 | : KIND_BY_PORT_TYPE[candidate.type] === entry.kind && !listed.has(candidate.uid), | ||
| 128 | ); | ||
| 129 | if (input) return input; | ||
| 130 | } | ||
| 131 | return null; | ||
| 132 | } | ||
apps/mobile/src/persistence/mobile-preferences.ts+13| ... | @@ -8,6 +8,7 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -8,6 +8,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 8 | import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; | 8 | import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 9 | import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; | 9 | import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; |
| 10 | import type { FollowUpBehavior } from "../lib/followUpBehavior"; | 10 | import type { FollowUpBehavior } from "../lib/followUpBehavior"; |
| 11 | import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority"; | ||
| 11 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; | 12 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; |
| 12 | import * as MobileDatabase from "./mobile-database"; | 13 | import * as MobileDatabase from "./mobile-database"; |
| 13 | import * as MobileSecureStorage from "./mobile-secure-storage"; | 14 | import * as MobileSecureStorage from "./mobile-secure-storage"; |
| ... | @@ -36,6 +37,8 @@ export interface Preferences { | ... | @@ -36,6 +37,8 @@ export interface Preferences { |
| 36 | * message sent during a running turn queues behind it or steers it. | 37 | * message sent during a running turn queues behind it or steers it. |
| 37 | */ | 38 | */ |
| 38 | readonly followUpBehavior?: FollowUpBehavior; | 39 | readonly followUpBehavior?: FollowUpBehavior; |
| 40 | /** Microphone kinds and remembered devices, most preferred first. iOS only. */ | ||
| 41 | readonly microphones?: ReadonlyArray<MicrophoneEntry>; | ||
| 39 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ | 42 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ |
| 40 | readonly projectGroupingEnabled?: boolean; | 43 | readonly projectGroupingEnabled?: boolean; |
| 41 | readonly projectGroupingMode?: SidebarProjectGroupingMode; | 44 | readonly projectGroupingMode?: SidebarProjectGroupingMode; |
| ... | @@ -107,6 +110,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -107,6 +110,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 107 | collapsedProjectGroups?: readonly string[]; | 110 | collapsedProjectGroups?: readonly string[]; |
| 108 | composerEnterBehavior?: ComposerEnterBehavior; | 111 | composerEnterBehavior?: ComposerEnterBehavior; |
| 109 | followUpBehavior?: FollowUpBehavior; | 112 | followUpBehavior?: FollowUpBehavior; |
| 113 | microphones?: ReadonlyArray<MicrophoneEntry>; | ||
| 110 | projectGroupingEnabled?: boolean; | 114 | projectGroupingEnabled?: boolean; |
| 111 | projectGroupingMode?: SidebarProjectGroupingMode; | 115 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 112 | planModeEnabled?: boolean; | 116 | planModeEnabled?: boolean; |
| ... | @@ -172,6 +176,15 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -172,6 +176,15 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 172 | if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") { | 176 | if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") { |
| 173 | preferences.followUpBehavior = parsed.followUpBehavior; | 177 | preferences.followUpBehavior = parsed.followUpBehavior; |
| 174 | } | 178 | } |
| 179 | if (Array.isArray(parsed.microphones)) { | ||
| 180 | preferences.microphones = parsed.microphones.flatMap((entry): MicrophoneEntry[] => { | ||
| 181 | if (typeof entry !== "object" || entry === null || !isMicrophoneKind(entry.kind)) return []; | ||
| 182 | if (!("uid" in entry)) return [{ kind: entry.kind }]; | ||
| 183 | return typeof entry.uid === "string" && typeof entry.name === "string" | ||
| 184 | ? [{ kind: entry.kind, uid: entry.uid, name: entry.name }] | ||
| 185 | : []; | ||
| 186 | }); | ||
| 187 | } | ||
| 175 | if (typeof parsed.projectGroupingEnabled === "boolean") { | 188 | if (typeof parsed.projectGroupingEnabled === "boolean") { |
| 176 | preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; | 189 | preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; |
| 177 | } | 190 | } |
docs/user/composer.md+7| ... | @@ -164,6 +164,13 @@ minutes long. Canceling, leaving the screen, or an audio interruption discards t | ... | @@ -164,6 +164,13 @@ minutes long. Canceling, leaving the screen, or an audio interruption discards t |
| 164 | recording and preserves your existing draft. While recording, the screen stays | 164 | recording and preserves your existing draft. While recording, the screen stays |
| 165 | awake; it can sleep normally once recording stops. | 165 | awake; it can sleep normally once recording stops. |
| 166 | 166 | ||
| 167 | Voice input records from the first connected microphone in **Settings → Microphone**. | ||
| 168 | The list mixes kinds (built-in, other wired, other Bluetooth, CarPlay) with wired and | ||
| 169 | Bluetooth devices you have dictated with, listed by name. Drag to rank them together: | ||
| 170 | for example, put **Other Bluetooth** above your AirPods so a new headset is preferred | ||
| 171 | while the AirPods microphone stays low. By default, CarPlay comes after the built-in | ||
| 172 | microphone. | ||
| 173 | |||
| 167 | Transcription runs on your device. T3 Code deletes the temporary audio after | 174 | Transcription runs on your device. T3 Code deletes the temporary audio after |
| 168 | transcription or cancellation; only the message text is sent when you submit. | 175 | transcription or cancellation; only the message text is sent when you submit. |
| 169 | 176 |