Files
paseo/packages/app/src/components/dictation-controls.tsx
T
Mohamed Boudra 6e1ec21c6a Keep Android dictation running through audio interruptions (#7200)
* fix(android): preserve dictation across audio interruptions

Playback focus loss does not mean microphone capture is unavailable. Keep dictation alive across media takeovers and wait for Android input silencing to clear, while preserving conversation interruption protection.

* test(android): verify dictation clears its selected audio route
2026-10-17 12:53:06 +07:00

412 lines
12 KiB
TypeScript

import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
interface DictationControlsProps {
volume: number;
duration: number;
transcript?: string;
isRecording: boolean;
isProcessing: boolean;
status: DictationStatus;
onStart: () => void;
onCancel: () => void;
onAccept: () => void;
onAcceptAndSend: () => void;
onRetry?: () => void;
onDiscard?: () => void;
disabled?: boolean;
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function DictationControls({
volume,
duration,
isRecording,
isProcessing,
status,
onStart,
onCancel,
onAccept,
onAcceptAndSend,
onRetry,
onDiscard,
disabled = false,
}: DictationControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const micButtonStyle = useMemo(
() => [styles.micButton, disabled && styles.buttonDisabled],
[disabled],
);
const timerTextStyle = useMemo(
() => [styles.timerText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cancelButtonStyle = useMemo(
() => [
styles.actionButton,
styles.actionButtonCancel,
actionsDisabled && !isFailed ? styles.buttonDisabled : undefined,
],
[actionsDisabled, isFailed],
);
if (!showActiveState) {
return (
<Pressable
onPress={onStart}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.start")}
style={micButtonStyle}
>
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
</Pressable>
);
}
return (
<View style={styles.activeContainer}>
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityLabel={t("message.dictation.cancel")}
style={cancelButtonStyle}
>
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
{actionsDisabled ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="small" color={theme.colors.foreground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityLabel={t("message.dictation.retry")}
style={[styles.actionButton, styles.actionButtonConfirm]}
>
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
) : null}
{!actionsDisabled && !isFailed ? (
<>
<Pressable
onPress={onAccept}
accessibilityLabel={t("message.dictation.insert")}
style={[styles.actionButton, styles.actionButtonSecondary]}
>
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityLabel={t("message.dictation.insertAndSend")}
style={[styles.actionButton, styles.actionButtonConfirm]}
>
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
</>
) : null}
</View>
</View>
);
}
/**
* Full-width overlay variant for the agent input footer.
* Uses blue background with white icons.
*/
export function DictationOverlay({
volume,
duration,
isRecording,
isProcessing,
status,
errorText,
onCancel,
onAccept,
onAcceptAndSend,
onRetry,
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const containerStyle = useMemo(
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
[theme.colors.accent],
);
const overlayCancelButtonStyle = useMemo(
() => [
overlayStyles.cancelButton,
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
],
[actionsDisabled, isFailed],
);
const overlayTimerTextStyle = useMemo(
() => [overlayStyles.timerText, { color: theme.colors.accentForeground }],
[theme.colors.accentForeground],
);
const overlayTranscriptTextStyle = useMemo(
() => [overlayStyles.transcriptText, { color: theme.colors.accentForeground, opacity: 0.95 }],
[theme.colors.accentForeground],
);
const overlayRetryButtonStyle = useMemo(
() => [overlayStyles.actionButton, { backgroundColor: theme.colors.accentForeground }],
[theme.colors.accentForeground],
);
const overlayConfirmButtonStyle = overlayRetryButtonStyle;
if (!showActiveState) {
return null;
}
return (
<View style={containerStyle}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.cancel")}
style={overlayCancelButtonStyle}
>
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
</Pressable>
<View style={overlayStyles.centerContainer}>
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry")}
</Text>
) : null}
</View>
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.retry")}
style={overlayRetryButtonStyle}
>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : null}
{!actionsDisabled && !isFailed ? (
<>
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.insert")}
style={[overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG]}
>
<Pencil
size={theme.iconSize.lg}
color={theme.colors.accentForeground}
strokeWidth={2.5}
/>
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.insertAndSend")}
style={overlayConfirmButtonStyle}
>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
</>
) : null}
</View>
</View>
);
}
const BUTTON_SIZE = 32;
const styles = StyleSheet.create((theme) => ({
micButton: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
activeContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
meterWrapper: {
width: 80,
alignItems: "center",
justifyContent: "center",
},
timerText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
fontVariant: ["tabular-nums"],
},
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
},
actionButton: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
borderWidth: theme.borderWidth[1],
},
actionButtonCancel: {
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
actionButtonSecondary: {
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
actionButtonConfirm: {
borderColor: theme.colors.foreground,
backgroundColor: theme.colors.foreground,
},
buttonDisabled: {
opacity: 0.4,
},
loadingContainer: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
alignItems: "center",
justifyContent: "center",
},
statusLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));
const OVERLAY_BUTTON_SIZE = 44;
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
const overlayStyles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
width: "100%",
borderRadius: theme.borderRadius["2xl"],
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: OVERLAY_VERTICAL_PADDING,
height: FOOTER_HEIGHT,
},
cancelButton: {
width: OVERLAY_BUTTON_SIZE,
height: OVERLAY_BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: "rgba(0, 0, 0, 0.15)",
alignItems: "center",
justifyContent: "center",
},
centerContainer: {
flex: 1,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
meterRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[4],
},
timerText: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
fontVariant: ["tabular-nums"],
},
transcriptText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
textAlign: "center",
paddingHorizontal: theme.spacing[2],
opacity: 0.9,
},
actionButtonsContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
actionButton: {
width: OVERLAY_BUTTON_SIZE,
height: OVERLAY_BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
loadingContainer: {
width: OVERLAY_BUTTON_SIZE,
height: OVERLAY_BUTTON_SIZE,
alignItems: "center",
justifyContent: "center",
},
}));
const OVERLAY_ACCEPT_BUTTON_BG = { backgroundColor: "rgba(255, 255, 255, 0.25)" };