1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import { AsyncResult } from "effect/reactivity";
4import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio";
5import { useCallback, useEffect, useState } from "react";
6import { AppState, Linking, Pressable, View } from "react-native";
7import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
11import { AppText as Text } from "../../components/AppText";
12import { cn } from "../../lib/cn";
13import {
14 isMicrophoneDevice,
15 type MicrophoneEntry,
16 type MicrophoneKind,
17 microphoneEntryKey,
18 microphoneOrder,
19} from "../../lib/microphonePriority";
20import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
21import { SettingsActionRow } from "./components/SettingsActionRow";
22import { SettingsDragHandle } from "./components/SettingsDragHandle";
23import { SettingsSection } from "./components/SettingsSection";
24
25const REMOVE_SIZE = 20;
26
27type MicrophonePermission = "checking" | "granted" | "ask" | "denied";
28
29/** Re-reads microphone access whenever the app returns from system Settings. */
30function 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
55const 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 */
96export 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
233function 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}