| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import { AsyncResult } from "effect/reactivity"; |
| 4 | import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio"; |
| 5 | import { useCallback, useEffect, useState } from "react"; |
| 6 | import { AppState, Linking, Pressable, View } from "react-native"; |
| 7 | import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | |
| 10 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| 11 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { cn } from "../../lib/cn"; |
| 13 | import { |
| 14 | isMicrophoneDevice, |
| 15 | type MicrophoneEntry, |
| 16 | type MicrophoneKind, |
| 17 | microphoneEntryKey, |
| 18 | microphoneOrder, |
| 19 | } from "../../lib/microphonePriority"; |
| 20 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 21 | import { SettingsActionRow } from "./components/SettingsActionRow"; |
| 22 | import { SettingsDragHandle } from "./components/SettingsDragHandle"; |
| 23 | import { SettingsSection } from "./components/SettingsSection"; |
| 24 | |
| 25 | const REMOVE_SIZE = 20; |
| 26 | |
| 27 | type MicrophonePermission = "checking" | "granted" | "ask" | "denied"; |
| 28 | |
| 29 | /** Re-reads microphone access whenever the app returns from system Settings. */ |
| 30 | function useMicrophonePermission() { |
| 31 | const [permission, setPermission] = useState<MicrophonePermission>("checking"); |
| 32 | const apply = useCallback( |
| 33 | (response: { readonly granted: boolean; readonly canAskAgain: boolean }) => |
| 34 | setPermission(response.granted ? "granted" : response.canAskAgain ? "ask" : "denied"), |
| 35 | [], |
| 36 | ); |
| 37 | const refresh = useCallback( |
| 38 | () => void getRecordingPermissionsAsync().then(apply, () => setPermission("ask")), |
| 39 | [apply], |
| 40 | ); |
| 41 | useEffect(() => { |
| 42 | refresh(); |
| 43 | const subscription = AppState.addEventListener("change", (state) => { |
| 44 | if (state === "active") refresh(); |
| 45 | }); |
| 46 | return () => subscription.remove(); |
| 47 | }, [refresh]); |
| 48 | const request = useCallback( |
| 49 | () => void requestRecordingPermissionsAsync().then(apply, refresh), |
| 50 | [apply, refresh], |
| 51 | ); |
| 52 | return { permission, request }; |
| 53 | } |
| 54 | |
| 55 | const KINDS: Record< |
| 56 | MicrophoneKind, |
| 57 | { |
| 58 | /** Shown under a remembered device's name. */ |
| 59 | readonly label: string; |
| 60 | readonly row: string; |
| 61 | readonly rowDescription: string; |
| 62 | readonly icon: AppSymbolName; |
| 63 | } |
| 64 | > = { |
| 65 | wired: { |
| 66 | label: "Wired", |
| 67 | row: "Other wired", |
| 68 | rowDescription: "Headsets and USB microphones not listed", |
| 69 | icon: "cable.connector", |
| 70 | }, |
| 71 | bluetooth: { |
| 72 | label: "Bluetooth", |
| 73 | row: "Other Bluetooth", |
| 74 | rowDescription: "AirPods, headsets, and cars not listed", |
| 75 | icon: "headphones", |
| 76 | }, |
| 77 | builtIn: { |
| 78 | label: "Built-in", |
| 79 | row: "Built-in microphone", |
| 80 | rowDescription: "The microphone on this device", |
| 81 | icon: "iphone", |
| 82 | }, |
| 83 | carPlay: { |
| 84 | label: "CarPlay", |
| 85 | row: "CarPlay", |
| 86 | rowDescription: "The car’s microphone", |
| 87 | icon: "car", |
| 88 | }, |
| 89 | }; |
| 90 | |
| 91 | /** |
| 92 | * Microphone kinds and remembered devices, in the order voice input prefers |
| 93 | * them. iOS only. A device joins the list the first time it is connected |
| 94 | * during dictation; until then its kind's row ranks it. |
| 95 | */ |
| 96 | export function SettingsMicrophoneRouteScreen() { |
| 97 | const insets = useSafeAreaInsets(); |
| 98 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 99 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 100 | const ready = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; |
| 101 | const entries = microphoneOrder( |
| 102 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined, |
| 103 | ); |
| 104 | const hasDevices = entries.some(isMicrophoneDevice); |
| 105 | const { permission, request: requestPermission } = useMicrophonePermission(); |
| 106 | const [editing, setEditing] = useState(false); |
| 107 | const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>( |
| 108 | null, |
| 109 | ); |
| 110 | const [rowHeight, setRowHeight] = useState(0); |
| 111 | // Each drop remounts the rows so the new order and the cleared offsets land in one frame. |
| 112 | const [drops, setDrops] = useState(0); |
| 113 | |
| 114 | // Transforms apply to the stored order, so a device recorded meanwhile is kept. |
| 115 | const move = (key: string, steps: number) => |
| 116 | savePreferences({ |
| 117 | transform: (current) => { |
| 118 | const next = [...microphoneOrder(current.microphones)]; |
| 119 | const from = next.findIndex((entry) => microphoneEntryKey(entry) === key); |
| 120 | const to = Math.min(next.length - 1, Math.max(0, from + steps)); |
| 121 | if (from === -1 || from === to) return {}; |
| 122 | const [moved] = next.splice(from, 1); |
| 123 | next.splice(to, 0, moved!); |
| 124 | return { microphones: next }; |
| 125 | }, |
| 126 | }); |
| 127 | const forget = (key: string) => |
| 128 | savePreferences({ |
| 129 | transform: (current) => ({ |
| 130 | microphones: microphoneOrder(current.microphones).filter( |
| 131 | (entry) => microphoneEntryKey(entry) !== key, |
| 132 | ), |
| 133 | }), |
| 134 | }); |
| 135 | // A lifted row takes a neighbour's slot once it has moved past half of that row. |
| 136 | const dropSteps = (from: number, translation: number) => |
| 137 | rowHeight === 0 |
| 138 | ? 0 |
| 139 | : Math.min(entries.length - 1, Math.max(0, from + Math.round(translation / rowHeight))) - |
| 140 | from; |
| 141 | |
| 142 | const dragFrom = |
| 143 | drag === null ? -1 : entries.findIndex((entry) => microphoneEntryKey(entry) === drag.key); |
| 144 | const dragTo = drag === null ? -1 : dragFrom + dropSteps(dragFrom, drag.translation); |
| 145 | |
| 146 | return ( |
| 147 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 148 | <ScrollView |
| 149 | contentInsetAdjustmentBehavior="automatic" |
| 150 | showsVerticalScrollIndicator={false} |
| 151 | className="flex-1" |
| 152 | contentContainerClassName="gap-3 px-5 pt-4" |
| 153 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 154 | > |
| 155 | {permission === "ask" || permission === "denied" ? ( |
| 156 | <> |
| 157 | <SettingsSection title="Microphone access"> |
| 158 | <SettingsActionRow |
| 159 | icon="mic" |
| 160 | label={permission === "ask" ? "Allow microphone access" : "Open Settings"} |
| 161 | onPress={ |
| 162 | permission === "ask" ? requestPermission : () => void Linking.openSettings() |
| 163 | } |
| 164 | /> |
| 165 | </SettingsSection> |
| 166 | <Text className="px-2 text-sm text-foreground-muted"> |
| 167 | {permission === "ask" |
| 168 | ? "Voice input needs microphone access before it can record or list your microphones." |
| 169 | : "Microphone access is off for The Knife. Turn it on in Settings to use voice input."} |
| 170 | </Text> |
| 171 | </> |
| 172 | ) : null} |
| 173 | <SettingsSection |
| 174 | title="Preferred order" |
| 175 | trailing={ |
| 176 | hasDevices ? ( |
| 177 | <Pressable |
| 178 | accessibilityRole="button" |
| 179 | onPress={() => setEditing((value) => !value)} |
| 180 | className="px-2 py-1 active:opacity-70" |
| 181 | > |
| 182 | <Text className="text-sm font-t3-medium text-foreground"> |
| 183 | {editing ? "Done" : "Edit"} |
| 184 | </Text> |
| 185 | </Pressable> |
| 186 | ) : undefined |
| 187 | } |
| 188 | > |
| 189 | {entries.map((entry, index) => { |
| 190 | const key = microphoneEntryKey(entry); |
| 191 | const shift = |
| 192 | dragFrom === -1 || index === dragFrom |
| 193 | ? 0 |
| 194 | : dragFrom < dragTo && index > dragFrom && index <= dragTo |
| 195 | ? -rowHeight |
| 196 | : dragFrom > dragTo && index < dragFrom && index >= dragTo |
| 197 | ? rowHeight |
| 198 | : 0; |
| 199 | return ( |
| 200 | <MicrophoneRow |
| 201 | key={`${key}:${drops}`} |
| 202 | entry={entry} |
| 203 | position={index + 1} |
| 204 | count={entries.length} |
| 205 | reorderable={ready} |
| 206 | editing={editing && hasDevices} |
| 207 | offset={index === dragFrom ? (drag?.translation ?? 0) : shift} |
| 208 | lifted={index === dragFrom} |
| 209 | onHeight={setRowHeight} |
| 210 | onDragStart={() => setDrag({ key, translation: 0 })} |
| 211 | onDragMove={(translation) => setDrag({ key, translation })} |
| 212 | onDragEnd={(translation, cancelled) => { |
| 213 | setDrag(null); |
| 214 | setDrops((count) => count + 1); |
| 215 | if (!cancelled) move(key, dropSteps(index, translation)); |
| 216 | }} |
| 217 | onStep={(direction) => move(key, direction === "up" ? -1 : 1)} |
| 218 | onForget={isMicrophoneDevice(entry) ? () => forget(key) : undefined} |
| 219 | /> |
| 220 | ); |
| 221 | })} |
| 222 | </SettingsSection> |
| 223 | <Text className="px-2 text-sm text-foreground-muted"> |
| 224 | Voice input records from the first connected microphone in this list. Wired and Bluetooth |
| 225 | devices are added by name after you use voice input with them connected; until then, the |
| 226 | Other row ranks them. |
| 227 | </Text> |
| 228 | </ScrollView> |
| 229 | </View> |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | function MicrophoneRow(props: { |
| 234 | readonly entry: MicrophoneEntry; |
| 235 | readonly position: number; |
| 236 | readonly count: number; |
| 237 | readonly reorderable: boolean; |
| 238 | readonly editing: boolean; |
| 239 | readonly offset: number; |
| 240 | readonly lifted: boolean; |
| 241 | readonly onHeight: (height: number) => void; |
| 242 | readonly onDragStart: () => void; |
| 243 | readonly onDragMove: (translation: number) => void; |
| 244 | readonly onDragEnd: (translation: number, cancelled: boolean) => void; |
| 245 | readonly onStep: (direction: "up" | "down") => void; |
| 246 | readonly onForget: (() => void) | undefined; |
| 247 | }) { |
| 248 | const { lifted, offset, entry } = props; |
| 249 | const kind = KINDS[entry.kind]; |
| 250 | const title = isMicrophoneDevice(entry) ? entry.name : kind.row; |
| 251 | const style = useAnimatedStyle(() => ({ |
| 252 | transform: [ |
| 253 | { |
| 254 | translateY: lifted |
| 255 | ? offset |
| 256 | : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }), |
| 257 | }, |
| 258 | ], |
| 259 | zIndex: lifted ? 1 : 0, |
| 260 | })); |
| 261 | return ( |
| 262 | <Reanimated.View |
| 263 | style={style} |
| 264 | onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)} |
| 265 | className={cn( |
| 266 | "flex-row items-center gap-4 pl-4", |
| 267 | props.position > 1 && !lifted && "border-t border-border-subtle", |
| 268 | lifted && "bg-grouped-card shadow-md", |
| 269 | !props.reorderable && "pr-4", |
| 270 | )} |
| 271 | > |
| 272 | {props.editing && props.onForget ? ( |
| 273 | <Pressable |
| 274 | accessibilityRole="button" |
| 275 | accessibilityLabel={`Forget ${title}`} |
| 276 | hitSlop={8} |
| 277 | onPress={props.onForget} |
| 278 | className="active:opacity-70" |
| 279 | > |
| 280 | <SymbolView |
| 281 | name="xmark.circle.fill" |
| 282 | size={REMOVE_SIZE} |
| 283 | tintColorClassName="accent-danger-foreground" |
| 284 | type="monochrome" |
| 285 | /> |
| 286 | </Pressable> |
| 287 | ) : props.editing ? ( |
| 288 | <View style={{ width: REMOVE_SIZE }} /> |
| 289 | ) : null} |
| 290 | <View |
| 291 | accessible |
| 292 | accessibilityLabel={`${title}, ${kind.label}, ${props.position} of ${props.count}`} |
| 293 | className="min-w-0 flex-1 flex-row items-center gap-4 py-4" |
| 294 | > |
| 295 | <SymbolView |
| 296 | name={kind.icon} |
| 297 | size={22} |
| 298 | tintColorClassName="accent-icon" |
| 299 | type="monochrome" |
| 300 | weight="regular" |
| 301 | /> |
| 302 | <View className="min-w-0 flex-1 gap-0.5"> |
| 303 | <Text numberOfLines={1} className="text-lg text-foreground"> |
| 304 | {title} |
| 305 | </Text> |
| 306 | <Text numberOfLines={1} className="text-sm text-foreground-muted"> |
| 307 | {isMicrophoneDevice(entry) ? kind.label : kind.rowDescription} |
| 308 | </Text> |
| 309 | </View> |
| 310 | </View> |
| 311 | {props.reorderable ? ( |
| 312 | <SettingsDragHandle |
| 313 | title={title} |
| 314 | canMoveUp={props.position > 1} |
| 315 | canMoveDown={props.position < props.count} |
| 316 | onStart={props.onDragStart} |
| 317 | onMove={props.onDragMove} |
| 318 | onEnd={props.onDragEnd} |
| 319 | onStep={props.onStep} |
| 320 | /> |
| 321 | ) : null} |
| 322 | </Reanimated.View> |
| 323 | ); |
| 324 | } |