fix(audio-messaging): android + fix dark theme label
This commit is contained in:
parent
aade47beb3
commit
a69321f82e
2 changed files with 287 additions and 64 deletions
|
|
@ -163,6 +163,15 @@ export default React.memo(function ChatInput({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestingMicRef.current = true;
|
requestingMicRef.current = true;
|
||||||
|
const startTs = Date.now();
|
||||||
|
const logStep = (step, extra) => {
|
||||||
|
console.log("[ChatInput] startRecording step", {
|
||||||
|
step,
|
||||||
|
platform: Platform.OS,
|
||||||
|
t: Date.now() - startTs,
|
||||||
|
...(extra ? extra : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
console.log("[ChatInput] startRecording invoked", {
|
console.log("[ChatInput] startRecording invoked", {
|
||||||
platform: Platform.OS,
|
platform: Platform.OS,
|
||||||
|
|
@ -176,6 +185,7 @@ export default React.memo(function ChatInput({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logStep("permission:begin");
|
||||||
console.log("Requesting microphone permission..");
|
console.log("Requesting microphone permission..");
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
const { granted, status } = await ensureMicPermission();
|
const { granted, status } = await ensureMicPermission();
|
||||||
|
|
@ -199,6 +209,8 @@ export default React.memo(function ChatInput({
|
||||||
} else {
|
} else {
|
||||||
// iOS microphone permission is handled inside useVoiceRecorder via expo-audio
|
// iOS microphone permission is handled inside useVoiceRecorder via expo-audio
|
||||||
}
|
}
|
||||||
|
logStep("permission:end");
|
||||||
|
|
||||||
// stop playback
|
// stop playback
|
||||||
if (player !== null) {
|
if (player !== null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -211,7 +223,13 @@ export default React.memo(function ChatInput({
|
||||||
console.log(
|
console.log(
|
||||||
"[ChatInput] startRecording delegating to useVoiceRecorder.start",
|
"[ChatInput] startRecording delegating to useVoiceRecorder.start",
|
||||||
);
|
);
|
||||||
await startVoiceRecorder();
|
logStep("useVoiceRecorder.start:begin");
|
||||||
|
await startVoiceRecorder({
|
||||||
|
// Android: permission is already handled via react-native-permissions in this component.
|
||||||
|
// expo-audio's requestRecordingPermissionsAsync can hang on Android 16.
|
||||||
|
skipPermissionRequest: Platform.OS === "android",
|
||||||
|
});
|
||||||
|
logStep("useVoiceRecorder.start:end");
|
||||||
|
|
||||||
// Announce once when recording starts.
|
// Announce once when recording starts.
|
||||||
if (lastRecordingAnnouncementRef.current !== "started") {
|
if (lastRecordingAnnouncementRef.current !== "started") {
|
||||||
|
|
@ -658,5 +676,6 @@ const useStyles = createStyles(({ fontSize, wp, theme: { colors } }) => ({
|
||||||
recordingExponentText: {
|
recordingExponentText: {
|
||||||
height: 32,
|
height: 32,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
color: colors.onSurface,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppState, Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
RecordingPresets,
|
RecordingPresets,
|
||||||
requestRecordingPermissionsAsync,
|
requestRecordingPermissionsAsync,
|
||||||
|
|
@ -9,11 +10,81 @@ import {
|
||||||
|
|
||||||
let hasLoggedAudioMode = false;
|
let hasLoggedAudioMode = false;
|
||||||
|
|
||||||
|
const nowMs = () => Date.now();
|
||||||
|
|
||||||
|
const withTimeout = async (promise, timeoutMs, label) => {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) {
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
let timeoutId;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`[useVoiceRecorder] timeout in ${label} after ${timeoutMs}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// race between actual promise and timeout
|
||||||
|
return await Promise.race([promise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForAppActive = async (timeoutMs) => {
|
||||||
|
if (AppState.currentState === "active") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub;
|
||||||
|
let timeoutId;
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
if (timeoutMs && timeoutMs > 0) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`[useVoiceRecorder] timeout in waitForAppActive after ${timeoutMs}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub = AppState.addEventListener("change", (state) => {
|
||||||
|
if (state === "active") {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sub?.remove?.();
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextFrame = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
export default function useVoiceRecorder() {
|
export default function useVoiceRecorder() {
|
||||||
const recorderRef = useRef(null);
|
const recorderRef = useRef(null);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [uri, setUri] = useState(null);
|
const [uri, setUri] = useState(null);
|
||||||
|
|
||||||
|
// Used to cancel stale/blocked starts so they cannot complete later
|
||||||
|
// (e.g. after a background->foreground transition).
|
||||||
|
const startAttemptRef = useRef(0);
|
||||||
|
|
||||||
// NOTE: `expo-audio` doesn't export `AudioRecorder` as a runtime JS class.
|
// NOTE: `expo-audio` doesn't export `AudioRecorder` as a runtime JS class.
|
||||||
// The supported API is `useAudioRecorder`, which returns a native-backed SharedObject.
|
// The supported API is `useAudioRecorder`, which returns a native-backed SharedObject.
|
||||||
const preset =
|
const preset =
|
||||||
|
|
@ -51,15 +122,75 @@ export default function useVoiceRecorder() {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(
|
||||||
|
async (options) => {
|
||||||
|
const opts = options || {};
|
||||||
|
const attemptId = ++startAttemptRef.current;
|
||||||
|
const attemptStart = nowMs();
|
||||||
|
const logStep = (step, extra) => {
|
||||||
|
console.log("[useVoiceRecorder] start step", {
|
||||||
|
step,
|
||||||
|
platform: Platform.OS,
|
||||||
|
attemptId,
|
||||||
|
t: nowMs() - attemptStart,
|
||||||
|
...(extra ? extra : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const assertNotCancelled = () => {
|
||||||
|
if (startAttemptRef.current !== attemptId) {
|
||||||
|
const err = new Error(
|
||||||
|
"[useVoiceRecorder] start cancelled/superseded",
|
||||||
|
);
|
||||||
|
err.__CANCELLED__ = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reset any previous recording before starting a new one
|
// Reset any previous recording before starting a new one
|
||||||
await cleanupRecording();
|
await cleanupRecording();
|
||||||
setUri(null);
|
setUri(null);
|
||||||
|
|
||||||
const permission = await requestRecordingPermissionsAsync();
|
// If the app is not active, do not attempt a start (it may complete later unexpectedly).
|
||||||
|
// This is especially important on Android where audio focus can be deferred.
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
logStep("waitForAppActive:begin", { appState: AppState.currentState });
|
||||||
|
await waitForAppActive(2500).catch((_e) => {
|
||||||
|
// If we cannot become active quickly, abort this start.
|
||||||
|
});
|
||||||
|
assertNotCancelled();
|
||||||
|
logStep("waitForAppActive:end", { appState: AppState.currentState });
|
||||||
|
|
||||||
|
if (AppState.currentState !== "active") {
|
||||||
|
throw new Error("[useVoiceRecorder] start aborted: app not active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield one frame to ensure the permission dialog/gesture cycle has fully finished.
|
||||||
|
await nextFrame();
|
||||||
|
assertNotCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
// - iOS: expo-audio permission API is the single source of truth.
|
||||||
|
// - Android: the app already requests RECORD_AUDIO via react-native-permissions
|
||||||
|
// in [`startRecording()`](src/containers/ChatInput/index.js:161).
|
||||||
|
// On Android 16 we observed `requestRecordingPermissionsAsync()` can hang,
|
||||||
|
// so we allow skipping it.
|
||||||
|
if (Platform.OS === "android" && opts.skipPermissionRequest === true) {
|
||||||
|
logStep("permissions:skipped");
|
||||||
|
} else {
|
||||||
|
logStep("permissions:begin");
|
||||||
|
const permission = await withTimeout(
|
||||||
|
requestRecordingPermissionsAsync(),
|
||||||
|
// iOS can sometimes take time if the system dialog appears; keep no timeout.
|
||||||
|
Platform.OS === "android" ? 4000 : 0,
|
||||||
|
"requestRecordingPermissionsAsync",
|
||||||
|
);
|
||||||
|
logStep("permissions:end", { granted: !!permission?.granted });
|
||||||
if (!permission?.granted) {
|
if (!permission?.granted) {
|
||||||
throw new Error("Microphone permission not granted");
|
throw new Error("Microphone permission not granted");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
assertNotCancelled();
|
||||||
|
|
||||||
// Configure audio mode for recording (iOS & Android)
|
// Configure audio mode for recording (iOS & Android)
|
||||||
const recordingAudioMode = {
|
const recordingAudioMode = {
|
||||||
|
|
@ -77,15 +208,56 @@ export default function useVoiceRecorder() {
|
||||||
hasLoggedAudioMode = true;
|
hasLoggedAudioMode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setAudioModeAsync(recordingAudioMode);
|
logStep("setAudioModeAsync:begin");
|
||||||
|
await withTimeout(
|
||||||
|
setAudioModeAsync(recordingAudioMode),
|
||||||
|
Platform.OS === "android" ? 4000 : 0,
|
||||||
|
"setAudioModeAsync",
|
||||||
|
);
|
||||||
|
logStep("setAudioModeAsync:end");
|
||||||
|
assertNotCancelled();
|
||||||
|
|
||||||
const prepareAndStart = async () => {
|
const prepareAndStart = async () => {
|
||||||
await setIsAudioActiveAsync(true).catch(() => {});
|
logStep("setIsAudioActiveAsync:begin");
|
||||||
|
await withTimeout(
|
||||||
|
setIsAudioActiveAsync(true).catch(() => {}),
|
||||||
|
Platform.OS === "android" ? 4000 : 0,
|
||||||
|
"setIsAudioActiveAsync",
|
||||||
|
);
|
||||||
|
logStep("setIsAudioActiveAsync:end");
|
||||||
|
assertNotCancelled();
|
||||||
|
|
||||||
console.log("[useVoiceRecorder] preparing recorder");
|
console.log("[useVoiceRecorder] preparing recorder");
|
||||||
await recorder.prepareToRecordAsync();
|
logStep("prepareToRecordAsync:begin");
|
||||||
|
await withTimeout(
|
||||||
|
recorder.prepareToRecordAsync(),
|
||||||
|
Platform.OS === "android" ? 7000 : 0,
|
||||||
|
"prepareToRecordAsync",
|
||||||
|
);
|
||||||
|
logStep("prepareToRecordAsync:end");
|
||||||
|
assertNotCancelled();
|
||||||
|
|
||||||
console.log("[useVoiceRecorder] starting recorder");
|
console.log("[useVoiceRecorder] starting recorder");
|
||||||
|
logStep("record:invoke");
|
||||||
recorder.record();
|
recorder.record();
|
||||||
|
|
||||||
|
// Some Android versions may take a moment to flip the native state.
|
||||||
|
// Avoid marking isRecording true until the recorder actually reports recording.
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
const startWait = nowMs();
|
||||||
|
while (nowMs() - startWait < 800) {
|
||||||
|
assertNotCancelled();
|
||||||
|
if (recorder.isRecording) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotCancelled();
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
|
logStep("record:started", { isRecording: !!recorder.isRecording });
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await prepareAndStart();
|
await prepareAndStart();
|
||||||
|
|
@ -103,6 +275,26 @@ export default function useVoiceRecorder() {
|
||||||
console.log("[useVoiceRecorder] recorder retry failed", _retryError);
|
console.log("[useVoiceRecorder] recorder retry failed", _retryError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One controlled retry for Android if we hit a timeout/hang.
|
||||||
|
// This prevents a later background->foreground from completing the old attempt.
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
try {
|
||||||
|
startAttemptRef.current = attemptId; // keep attempt active for the retry
|
||||||
|
await cleanupRecording();
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
assertNotCancelled();
|
||||||
|
logStep("androidRetry:begin");
|
||||||
|
await prepareAndStart();
|
||||||
|
logStep("androidRetry:success");
|
||||||
|
return;
|
||||||
|
} catch (_androidRetryError) {
|
||||||
|
console.log(
|
||||||
|
"[useVoiceRecorder] android retry failed",
|
||||||
|
_androidRetryError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (recorderRef.current?.isRecording) {
|
if (recorderRef.current?.isRecording) {
|
||||||
await recorderRef.current.stop();
|
await recorderRef.current.stop();
|
||||||
|
|
@ -115,7 +307,9 @@ export default function useVoiceRecorder() {
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [cleanupRecording, recorder]);
|
},
|
||||||
|
[cleanupRecording, recorder],
|
||||||
|
);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
const recorder = recorderRef.current;
|
const recorder = recorderRef.current;
|
||||||
|
|
@ -142,7 +336,17 @@ export default function useVoiceRecorder() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Cancel any pending start when the app transitions away from active.
|
||||||
|
// This prevents a stalled promise from completing later and starting recording unexpectedly.
|
||||||
|
const sub = AppState.addEventListener("change", (state) => {
|
||||||
|
if (state !== "active") {
|
||||||
|
startAttemptRef.current += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
try {
|
||||||
|
sub.remove();
|
||||||
|
} catch (_e) {}
|
||||||
const recorder = recorderRef.current;
|
const recorder = recorderRef.current;
|
||||||
if (recorder) {
|
if (recorder) {
|
||||||
if (recorder.isRecording) {
|
if (recorder.isRecording) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue