421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Switch,
|
|
Text,
|
|
View
|
|
} from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { useFocusEffect, useRouter } from 'expo-router'
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
|
|
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
|
import { loadHosts } from '../src/transport/host-store'
|
|
import type { HostProfile } from '../src/transport/types'
|
|
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
|
import type { RpcClient } from '../src/transport/rpc-client'
|
|
import { BottomDrawer } from '../src/components/BottomDrawer'
|
|
import { VoiceModelList } from '../src/components/VoiceModelList'
|
|
import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller'
|
|
import {
|
|
deleteDictationModel,
|
|
downloadDictationModel,
|
|
fetchDictationSetup,
|
|
isModelInFlight,
|
|
setDictationConfig,
|
|
type MobileSpeechModel,
|
|
type MobileSpeechSetup
|
|
} from '../src/dictation/mobile-dictation-setup'
|
|
|
|
const POLL_INTERVAL_MS = 1500
|
|
|
|
const DICTATION_MODES = [
|
|
{ value: 'toggle', label: 'Toggle' },
|
|
{ value: 'hold', label: 'Hold' }
|
|
] as const
|
|
|
|
type ModelBusyAction = { modelId: string; type: 'download' | 'select' | 'delete' }
|
|
|
|
export default function VoiceSettingsScreen(): React.JSX.Element {
|
|
const router = useRouter()
|
|
const insets = useSafeAreaInsets()
|
|
|
|
const [hosts, setHosts] = useState<HostProfile[]>([])
|
|
useEffect(() => {
|
|
void loadHosts().then(setHosts)
|
|
}, [])
|
|
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
|
const hostClients = useAllHostClients(hostIds)
|
|
// Voice dictation runs on the paired desktop, so pick the first connected host.
|
|
const client: RpcClient | null = useMemo(
|
|
() => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
|
|
[hostClients]
|
|
)
|
|
|
|
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
|
|
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
|
|
const [routeFocused, setRouteFocused] = useState(false)
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
setRouteFocused(true)
|
|
return () => setRouteFocused(false)
|
|
}, [])
|
|
)
|
|
|
|
const refresh = useCallback(async (): Promise<boolean | undefined> => {
|
|
if (!client) {
|
|
return false
|
|
}
|
|
try {
|
|
const next = await fetchDictationSetup(client)
|
|
setSetup(next)
|
|
setError(null)
|
|
return next.models.some(isModelInFlight)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
|
|
return undefined
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [client])
|
|
|
|
const polling = setup?.models.some(isModelInFlight) ?? false
|
|
const refreshSetup = useDictationSetupPoller({
|
|
visible: routeFocused && client !== null,
|
|
polling,
|
|
refresh,
|
|
intervalMs: POLL_INTERVAL_MS
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (routeFocused && client && setup === null) {
|
|
setLoading(true)
|
|
}
|
|
}, [routeFocused, client, setup])
|
|
|
|
const handleToggleEnabled = useCallback(
|
|
async (enabled: boolean) => {
|
|
if (!client) {
|
|
return
|
|
}
|
|
setError(null)
|
|
// Optimistic flip so the switch responds instantly; reconcile below.
|
|
setSetup((prev) => (prev ? { ...prev, enabled } : prev))
|
|
try {
|
|
setSetup(await setDictationConfig(client, { enabled }))
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not update')
|
|
void refreshSetup()
|
|
}
|
|
},
|
|
[client, refreshSetup]
|
|
)
|
|
|
|
const handleSelectMode = useCallback(
|
|
async (dictationMode: 'toggle' | 'hold') => {
|
|
if (!client) {
|
|
return
|
|
}
|
|
setError(null)
|
|
setSetup((prev) => (prev ? { ...prev, dictationMode } : prev))
|
|
try {
|
|
setSetup(await setDictationConfig(client, { dictationMode }))
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not update')
|
|
void refreshSetup()
|
|
}
|
|
},
|
|
[client, refreshSetup]
|
|
)
|
|
|
|
const handleUseModel = useCallback(
|
|
async (model: MobileSpeechModel) => {
|
|
if (!client) {
|
|
return
|
|
}
|
|
setBusyAction({ modelId: model.id, type: 'select' })
|
|
setError(null)
|
|
try {
|
|
setSetup(await setDictationConfig(client, { enabled: true, modelId: model.id }))
|
|
setModelDrawerOpen(false)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not select model')
|
|
} finally {
|
|
setBusyAction(null)
|
|
}
|
|
},
|
|
[client]
|
|
)
|
|
|
|
const handleDownload = useCallback(
|
|
async (model: MobileSpeechModel) => {
|
|
if (!client) {
|
|
return
|
|
}
|
|
setBusyAction({ modelId: model.id, type: 'download' })
|
|
setError(null)
|
|
try {
|
|
await downloadDictationModel(client, model.id)
|
|
await refreshSetup()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Download failed')
|
|
} finally {
|
|
setBusyAction(null)
|
|
}
|
|
},
|
|
[client, refreshSetup]
|
|
)
|
|
|
|
const handleDelete = useCallback(
|
|
async (model: MobileSpeechModel) => {
|
|
if (!client) {
|
|
return
|
|
}
|
|
const deletedSelectedModel = setup?.selectedModelId === model.id
|
|
setBusyAction({ modelId: model.id, type: 'delete' })
|
|
setError(null)
|
|
try {
|
|
setSetup(await deleteDictationModel(client, model.id))
|
|
if (deletedSelectedModel) {
|
|
setModelDrawerOpen(false)
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Delete failed')
|
|
} finally {
|
|
setBusyAction(null)
|
|
}
|
|
},
|
|
[client, setup?.selectedModelId]
|
|
)
|
|
|
|
const enabled = setup?.enabled ?? false
|
|
const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId)
|
|
const selectedModelLabel = selectedModel?.label ?? 'None selected'
|
|
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
|
<View style={styles.topRow}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<ChevronLeft size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
<Text style={styles.heading}>Voice</Text>
|
|
</View>
|
|
|
|
{!client ? (
|
|
<View style={[styles.section, styles.sectionTopGap]}>
|
|
<Text style={styles.emptyText}>Connect to a desktop to manage voice settings.</Text>
|
|
</View>
|
|
) : loading && setup === null ? (
|
|
<View style={styles.loading}>
|
|
<ActivityIndicator color={colors.textSecondary} />
|
|
</View>
|
|
) : setup === null ? (
|
|
<View style={[styles.section, styles.sectionTopGap]}>
|
|
<Text style={styles.errorText}>{error ?? 'Failed to load voice settings.'}</Text>
|
|
</View>
|
|
) : (
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<Text style={styles.groupHeading}>DICTATION</Text>
|
|
<View style={[styles.section, styles.sectionTopGap]}>
|
|
<View style={styles.row}>
|
|
<View style={styles.rowContent}>
|
|
<Text style={styles.rowLabel}>Enable Voice Dictation</Text>
|
|
<Text style={styles.rowSublabel}>
|
|
Dictate text into any focused pane on your desktop.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={enabled}
|
|
onValueChange={(v) => void handleToggleEnabled(v)}
|
|
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
|
thumbColor={colors.textPrimary}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.separator} />
|
|
|
|
<View
|
|
style={[styles.row, !enabled && styles.disabled]}
|
|
pointerEvents={enabled ? 'auto' : 'none'}
|
|
>
|
|
<View style={styles.rowContent}>
|
|
<Text style={styles.rowLabel}>Dictation Mode</Text>
|
|
<Text style={styles.rowSublabel}>
|
|
Toggle: press once to start, again to stop. Hold: dictate while held.
|
|
</Text>
|
|
</View>
|
|
<View style={styles.segmented}>
|
|
{DICTATION_MODES.map((mode) => {
|
|
const active = setup.dictationMode === mode.value
|
|
return (
|
|
<Pressable
|
|
key={mode.value}
|
|
onPress={() => void handleSelectMode(mode.value)}
|
|
style={[styles.segment, active && styles.segmentActive]}
|
|
>
|
|
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
|
|
{mode.label}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={[styles.groupHeading, styles.inputGroupGap]}>SPEECH MODEL</Text>
|
|
<View style={[styles.section, styles.sectionTopGap]}>
|
|
<Pressable
|
|
style={({ pressed }) => [
|
|
styles.row,
|
|
!enabled && styles.disabled,
|
|
pressed && styles.rowPressed
|
|
]}
|
|
disabled={!enabled}
|
|
onPress={() => setModelDrawerOpen(true)}
|
|
>
|
|
<View style={styles.rowContent}>
|
|
<Text style={styles.rowLabel}>Speech Model</Text>
|
|
<Text style={styles.rowSublabel} numberOfLines={1}>
|
|
{selectedModelLabel}
|
|
</Text>
|
|
</View>
|
|
<ChevronRight size={18} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
</ScrollView>
|
|
)}
|
|
|
|
<BottomDrawer visible={modelDrawerOpen} onClose={() => setModelDrawerOpen(false)}>
|
|
<Text style={styles.drawerTitle}>Speech Model</Text>
|
|
{setup ? (
|
|
<VoiceModelList
|
|
setup={setup}
|
|
disabled={false}
|
|
busyAction={busyAction}
|
|
onUseModel={(m) => void handleUseModel(m)}
|
|
onDownload={(m) => void handleDownload(m)}
|
|
onDelete={(m) => void handleDelete(m)}
|
|
/>
|
|
) : null}
|
|
</BottomDrawer>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgBase,
|
|
paddingHorizontal: spacing.lg
|
|
},
|
|
topRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: spacing.sm,
|
|
marginBottom: spacing.lg
|
|
},
|
|
backButton: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: spacing.sm
|
|
},
|
|
heading: {
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary
|
|
},
|
|
scrollContent: {
|
|
paddingBottom: spacing.xl
|
|
},
|
|
loading: { paddingVertical: spacing.xl, alignItems: 'center' },
|
|
groupHeading: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
color: colors.textMuted,
|
|
letterSpacing: 0.5,
|
|
marginBottom: spacing.xs,
|
|
paddingHorizontal: spacing.xs
|
|
},
|
|
section: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: radii.card,
|
|
overflow: 'hidden'
|
|
},
|
|
sectionTopGap: { marginTop: spacing.sm },
|
|
inputGroupGap: { marginTop: spacing.xl },
|
|
disabled: { opacity: 0.5 },
|
|
emptyText: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textSecondary,
|
|
padding: spacing.md
|
|
},
|
|
errorText: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.statusRed,
|
|
padding: spacing.md
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm + 2,
|
|
paddingVertical: spacing.md,
|
|
paddingHorizontal: spacing.md + 2
|
|
},
|
|
rowPressed: { backgroundColor: colors.bgRaised },
|
|
rowContent: { flex: 1 },
|
|
rowLabel: {
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '500',
|
|
color: colors.textPrimary
|
|
},
|
|
drawerTitle: {
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
paddingHorizontal: spacing.md + 2,
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: spacing.xs
|
|
},
|
|
rowSublabel: {
|
|
fontSize: typography.bodySize - 2,
|
|
color: colors.textSecondary,
|
|
marginTop: 2
|
|
},
|
|
separator: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginHorizontal: spacing.md
|
|
},
|
|
segmented: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.bgBase,
|
|
borderRadius: radii.button,
|
|
padding: 2
|
|
},
|
|
segment: {
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: 6,
|
|
borderRadius: radii.button - 1
|
|
},
|
|
segmentActive: { backgroundColor: colors.bgRaised },
|
|
segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' },
|
|
segmentTextActive: { color: colors.textPrimary },
|
|
error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md }
|
|
})
|