Merge branch 'settings-ux-rehaul'
This commit is contained in:
@@ -5,7 +5,7 @@ export default {
|
|||||||
expo: {
|
expo: {
|
||||||
name: "Paseo",
|
name: "Paseo",
|
||||||
slug: "voice-mobile",
|
slug: "voice-mobile",
|
||||||
version: "1.0.0",
|
version: "0.1.0",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
icon: "./assets/images/icon.png",
|
icon: "./assets/images/icon.png",
|
||||||
scheme: "paseo",
|
scheme: "paseo",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@paseo/app",
|
"name": "@paseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
|||||||
@@ -425,7 +425,6 @@ export default function RootLayout() {
|
|||||||
options={{ gestureEnabled: false }}
|
options={{ gestureEnabled: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="audio-test" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
|
|||||||
@@ -1,527 +0,0 @@
|
|||||||
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
|
||||||
import { useAudioRecorder } from '../hooks/use-audio-recorder';
|
|
||||||
import { useAudioPlayer } from '../hooks/use-audio-player';
|
|
||||||
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: theme.colors.palette.white,
|
|
||||||
},
|
|
||||||
scrollViewDark: {
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
padding: theme.spacing[6],
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: theme.fontSize['3xl'],
|
|
||||||
fontWeight: theme.fontWeight.bold,
|
|
||||||
color: theme.colors.palette.gray[900],
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
titleDark: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.palette.gray[600],
|
|
||||||
marginBottom: theme.spacing[8],
|
|
||||||
},
|
|
||||||
subtitleDark: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
marginBottom: theme.spacing[8],
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: theme.fontSize.lg,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
color: theme.colors.palette.gray[900],
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
sectionTitleDark: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
permissionCard: {
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
permissionGranted: {
|
|
||||||
backgroundColor: theme.colors.palette.green[100],
|
|
||||||
},
|
|
||||||
permissionGrantedDark: {
|
|
||||||
backgroundColor: theme.colors.palette.green[900],
|
|
||||||
},
|
|
||||||
permissionDenied: {
|
|
||||||
backgroundColor: theme.colors.palette.red[100],
|
|
||||||
},
|
|
||||||
permissionDeniedDark: {
|
|
||||||
backgroundColor: theme.colors.palette.red[900],
|
|
||||||
},
|
|
||||||
permissionText: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
},
|
|
||||||
permissionTextGranted: {
|
|
||||||
color: theme.colors.palette.green[800],
|
|
||||||
},
|
|
||||||
permissionTextGrantedDark: {
|
|
||||||
color: theme.colors.palette.green[200],
|
|
||||||
},
|
|
||||||
permissionTextDenied: {
|
|
||||||
color: theme.colors.palette.red[800],
|
|
||||||
},
|
|
||||||
permissionTextDeniedDark: {
|
|
||||||
color: theme.colors.palette.red[200],
|
|
||||||
},
|
|
||||||
statusCard: {
|
|
||||||
marginBottom: theme.spacing[4],
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
backgroundColor: theme.colors.palette.gray[100],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
statusCardDark: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
statusLabel: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
color: theme.colors.palette.gray[700],
|
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
statusLabelDark: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
statusValue: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.palette.gray[900],
|
|
||||||
},
|
|
||||||
statusValueDark: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
controlsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
marginBottom: theme.spacing[4],
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
flex: 1,
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
buttonBlue: {
|
|
||||||
backgroundColor: theme.colors.palette.blue[500],
|
|
||||||
},
|
|
||||||
buttonBlueDark: {
|
|
||||||
backgroundColor: theme.colors.palette.blue[600],
|
|
||||||
},
|
|
||||||
buttonRed: {
|
|
||||||
backgroundColor: theme.colors.palette.red[500],
|
|
||||||
},
|
|
||||||
buttonRedDark: {
|
|
||||||
backgroundColor: theme.colors.palette.red[600],
|
|
||||||
},
|
|
||||||
buttonGreen: {
|
|
||||||
backgroundColor: theme.colors.palette.green[500],
|
|
||||||
},
|
|
||||||
buttonGreenDark: {
|
|
||||||
backgroundColor: theme.colors.palette.green[600],
|
|
||||||
},
|
|
||||||
buttonPurple: {
|
|
||||||
backgroundColor: theme.colors.palette.purple[500],
|
|
||||||
},
|
|
||||||
buttonPurpleDark: {
|
|
||||||
backgroundColor: theme.colors.palette.purple[600],
|
|
||||||
},
|
|
||||||
buttonOrange: {
|
|
||||||
backgroundColor: theme.colors.palette.orange[500],
|
|
||||||
},
|
|
||||||
buttonOrangeDark: {
|
|
||||||
backgroundColor: theme.colors.palette.orange[600],
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
|
||||||
backgroundColor: theme.colors.palette.gray[300],
|
|
||||||
},
|
|
||||||
buttonDisabledDark: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
color: theme.colors.palette.white,
|
|
||||||
},
|
|
||||||
buttonTextDisabled: {
|
|
||||||
color: theme.colors.palette.gray[500],
|
|
||||||
},
|
|
||||||
buttonTextDisabledDark: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
fullWidthButton: {
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
infoCard: {
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
backgroundColor: theme.colors.palette.blue[50],
|
|
||||||
},
|
|
||||||
infoCardDark: {
|
|
||||||
backgroundColor: theme.colors.palette.blue[950],
|
|
||||||
},
|
|
||||||
infoCardLabel: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
color: theme.colors.palette.blue[700],
|
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
infoCardLabelDark: {
|
|
||||||
color: theme.colors.palette.blue[300],
|
|
||||||
},
|
|
||||||
infoCardValue: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.palette.blue[900],
|
|
||||||
},
|
|
||||||
infoCardValueDark: {
|
|
||||||
color: theme.colors.palette.blue[100],
|
|
||||||
},
|
|
||||||
infoCardSubtext: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.palette.blue[600],
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
infoCardSubtextDark: {
|
|
||||||
color: theme.colors.palette.blue[400],
|
|
||||||
},
|
|
||||||
configCard: {
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
backgroundColor: theme.colors.palette.gray[50],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
configCardDark: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
configTitle: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
color: theme.colors.palette.gray[700],
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
configTitleDark: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
configText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.palette.gray[600],
|
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
configTextDark: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
gap3: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default function AudioTestScreen() {
|
|
||||||
const [permissionGranted, setPermissionGranted] = useState(false);
|
|
||||||
const [recordingStatus, setRecordingStatus] = useState<'idle' | 'recording' | 'processing'>('idle');
|
|
||||||
const [lastRecordingSize, setLastRecordingSize] = useState<number | null>(null);
|
|
||||||
const [playbackStatus, setPlaybackStatus] = useState<string>('No audio playing');
|
|
||||||
const [lastRecordedBlob, setLastRecordedBlob] = useState<Blob | null>(null);
|
|
||||||
|
|
||||||
const audioRecorder = useAudioRecorder({
|
|
||||||
sampleRate: 16000,
|
|
||||||
numberOfChannels: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioPlayer = useAudioPlayer();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAndRequestPermissions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function checkAndRequestPermissions() {
|
|
||||||
try {
|
|
||||||
const { status } = await getRecordingPermissionsAsync();
|
|
||||||
|
|
||||||
if (status === 'granted') {
|
|
||||||
setPermissionGranted(true);
|
|
||||||
} else {
|
|
||||||
const { granted } = await requestRecordingPermissionsAsync();
|
|
||||||
setPermissionGranted(granted);
|
|
||||||
|
|
||||||
if (!granted) {
|
|
||||||
Alert.alert(
|
|
||||||
'Permission Required',
|
|
||||||
'Microphone permission is required to test audio recording.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AudioTest] Permission error:', error);
|
|
||||||
Alert.alert('Error', 'Failed to check/request microphone permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStartRecording() {
|
|
||||||
if (!permissionGranted) {
|
|
||||||
Alert.alert('Permission Required', 'Please grant microphone permission first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setRecordingStatus('recording');
|
|
||||||
setLastRecordingSize(null);
|
|
||||||
await audioRecorder.start();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[AudioTest] Start recording error:', error);
|
|
||||||
Alert.alert('Recording Error', error.message);
|
|
||||||
setRecordingStatus('idle');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStopRecording() {
|
|
||||||
try {
|
|
||||||
setRecordingStatus('processing');
|
|
||||||
const audioBlob = await audioRecorder.stop();
|
|
||||||
|
|
||||||
setLastRecordingSize(audioBlob.size);
|
|
||||||
setLastRecordedBlob(audioBlob);
|
|
||||||
setRecordingStatus('idle');
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
'Recording Complete',
|
|
||||||
`Audio recorded successfully!\nSize: ${audioBlob.size} bytes\nType: ${audioBlob.type}`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[AudioTest] Stop recording error:', error);
|
|
||||||
Alert.alert('Recording Error', error.message);
|
|
||||||
setRecordingStatus('idle');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePlayLastRecording() {
|
|
||||||
if (!lastRecordedBlob) {
|
|
||||||
Alert.alert('No Recording', 'Please record audio first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setPlaybackStatus('Playing last recording...');
|
|
||||||
const duration = await audioPlayer.play(lastRecordedBlob);
|
|
||||||
setPlaybackStatus(`Playback complete (${duration.toFixed(2)}s)`);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[AudioTest] Playback error:', error);
|
|
||||||
Alert.alert('Playback Error', error.message);
|
|
||||||
setPlaybackStatus('Playback failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePlayTestAudio() {
|
|
||||||
// Create a test audio blob (silent audio for testing)
|
|
||||||
// In a real scenario, this would be audio from the server
|
|
||||||
try {
|
|
||||||
setPlaybackStatus('Playing test audio...');
|
|
||||||
|
|
||||||
// For now, we'll show an alert since we don't have a test audio file
|
|
||||||
Alert.alert(
|
|
||||||
'Test Audio',
|
|
||||||
'This would play a test audio file. Try recording and playing back instead.'
|
|
||||||
);
|
|
||||||
setPlaybackStatus('Test audio not implemented');
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[AudioTest] Test playback error:', error);
|
|
||||||
Alert.alert('Playback Error', error.message);
|
|
||||||
setPlaybackStatus('Test playback failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStopPlayback() {
|
|
||||||
audioPlayer.stop();
|
|
||||||
setPlaybackStatus('Playback stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView style={[styles.scrollView, styles.scrollViewDark]}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
{/* Header */}
|
|
||||||
<Text style={[styles.title, styles.titleDark]}>
|
|
||||||
Audio Test
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.subtitle, styles.subtitleDark]}>
|
|
||||||
Test audio recording and playback functionality
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Permission Status */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
|
|
||||||
Permission Status
|
|
||||||
</Text>
|
|
||||||
<View style={[
|
|
||||||
styles.permissionCard,
|
|
||||||
permissionGranted ? styles.permissionGrantedDark : styles.permissionDeniedDark
|
|
||||||
]}>
|
|
||||||
<Text style={[
|
|
||||||
styles.permissionText,
|
|
||||||
permissionGranted ? styles.permissionTextGrantedDark : styles.permissionTextDeniedDark
|
|
||||||
]}>
|
|
||||||
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Recording Section */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
|
|
||||||
Audio Recording
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Recording Status */}
|
|
||||||
<View style={[styles.statusCard, styles.statusCardDark]}>
|
|
||||||
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
|
|
||||||
Status
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.statusValue, styles.statusValueDark]}>
|
|
||||||
{recordingStatus === 'idle' && 'Ready to record'}
|
|
||||||
{recordingStatus === 'recording' && '🔴 Recording...'}
|
|
||||||
{recordingStatus === 'processing' && 'Processing...'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Recording Controls */}
|
|
||||||
<View style={styles.controlsRow}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleStartRecording}
|
|
||||||
disabled={recordingStatus !== 'idle' || !permissionGranted}
|
|
||||||
style={[
|
|
||||||
styles.button,
|
|
||||||
(recordingStatus !== 'idle' || !permissionGranted)
|
|
||||||
? styles.buttonDisabledDark
|
|
||||||
: styles.buttonBlueDark
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.buttonText,
|
|
||||||
(recordingStatus !== 'idle' || !permissionGranted) && styles.buttonTextDisabledDark
|
|
||||||
]}>
|
|
||||||
Start Recording
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={handleStopRecording}
|
|
||||||
disabled={recordingStatus !== 'recording'}
|
|
||||||
style={[
|
|
||||||
styles.button,
|
|
||||||
recordingStatus !== 'recording'
|
|
||||||
? styles.buttonDisabledDark
|
|
||||||
: styles.buttonRedDark
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.buttonText,
|
|
||||||
recordingStatus !== 'recording' && styles.buttonTextDisabledDark
|
|
||||||
]}>
|
|
||||||
Stop Recording
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Last Recording Info */}
|
|
||||||
{lastRecordingSize !== null && (
|
|
||||||
<View style={[styles.infoCard, styles.infoCardDark]}>
|
|
||||||
<Text style={[styles.infoCardLabel, styles.infoCardLabelDark]}>
|
|
||||||
Last Recording
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.infoCardValue, styles.infoCardValueDark]}>
|
|
||||||
Size: {lastRecordingSize} bytes
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.infoCardSubtext, styles.infoCardSubtextDark]}>
|
|
||||||
Type: audio/m4a
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Playback Section */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
|
|
||||||
Audio Playback
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Playback Status */}
|
|
||||||
<View style={[styles.statusCard, styles.statusCardDark]}>
|
|
||||||
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
|
|
||||||
Status
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.statusValue, styles.statusValueDark]}>
|
|
||||||
{playbackStatus}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Playback Controls */}
|
|
||||||
<View style={styles.gap3}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handlePlayLastRecording}
|
|
||||||
disabled={!lastRecordedBlob}
|
|
||||||
style={[
|
|
||||||
styles.fullWidthButton,
|
|
||||||
!lastRecordedBlob
|
|
||||||
? styles.buttonDisabledDark
|
|
||||||
: styles.buttonGreenDark
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.buttonText,
|
|
||||||
!lastRecordedBlob && styles.buttonTextDisabledDark
|
|
||||||
]}>
|
|
||||||
Play Last Recording
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={handlePlayTestAudio}
|
|
||||||
style={[styles.fullWidthButton, styles.buttonPurpleDark]}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>
|
|
||||||
Play Test Audio
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={handleStopPlayback}
|
|
||||||
style={[styles.fullWidthButton, styles.buttonOrangeDark]}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>
|
|
||||||
Stop Playback
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Audio Settings Info */}
|
|
||||||
<View style={[styles.configCard, styles.configCardDark]}>
|
|
||||||
<Text style={[styles.configTitle, styles.configTitleDark]}>
|
|
||||||
Audio Configuration
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.configText, styles.configTextDark]}>
|
|
||||||
• Sample Rate: 16000 Hz
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.configText, styles.configTextDark]}>
|
|
||||||
• Channels: 1 (Mono)
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.configText, styles.configTextDark]}>
|
|
||||||
• Format: M4A/AAC
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.configText, styles.configTextDark]}>
|
|
||||||
• Optimized for voice/speech
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Alert, Platform, Pressable, Text, View } from "react-native";
|
import { Alert, Platform, Pressable, Text, View } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult } from "expo-camera";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { NameHostModal } from "@/components/name-host-modal";
|
||||||
|
import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints";
|
||||||
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -133,11 +137,56 @@ export default function PairScanScreen() {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
const params = useLocalSearchParams<{ source?: string; targetServerId?: string }>();
|
||||||
|
const source = typeof params.source === "string" ? params.source : "settings";
|
||||||
|
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||||
|
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
||||||
|
},
|
||||||
|
[pendingNameHost]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const returnToSource = useCallback(
|
||||||
|
(serverId: string) => {
|
||||||
|
if (source === "onboarding") {
|
||||||
|
router.replace({ pathname: "/", params: { serverId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source === "editHost" && targetServerId) {
|
||||||
|
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// settings (default): return to previous screen
|
||||||
|
try {
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
router.replace("/settings");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, source, targetServerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeToSource = useCallback(() => {
|
||||||
|
if (source === "editHost" && targetServerId) {
|
||||||
|
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
router.replace("/settings");
|
||||||
|
}
|
||||||
|
}, [router, source, targetServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") return;
|
if (Platform.OS === "web") return;
|
||||||
@@ -147,6 +196,7 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (result: BarcodeScanningResult) => {
|
async (result: BarcodeScanningResult) => {
|
||||||
|
if (pendingNameHost) return;
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
@@ -156,8 +206,26 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPairing(true);
|
setIsPairing(true);
|
||||||
|
const idx = offerUrl.indexOf("#offer=");
|
||||||
|
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
|
||||||
|
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||||
|
const offer = ConnectionOfferSchema.parse(offerPayload);
|
||||||
|
|
||||||
|
if (targetServerId && offer.serverId !== targetServerId) {
|
||||||
|
lastScannedRef.current = null;
|
||||||
|
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
returnToSource(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastScannedRef.current = null;
|
lastScannedRef.current = null;
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||||
@@ -166,7 +234,7 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPairing, router, upsertDaemonFromOfferUrl]
|
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
@@ -184,7 +252,7 @@ export default function PairScanScreen() {
|
|||||||
<Text style={styles.permissionBody}>
|
<Text style={styles.permissionBody}>
|
||||||
QR scanning isn't supported in the web build. Use "Paste link" instead.
|
QR scanning isn't supported in the web build. Use "Paste link" instead.
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable style={styles.permissionButton} onPress={() => router.replace("/settings")}>
|
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||||
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -197,9 +265,28 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{pendingNameHost ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={pendingNameHost.serverId}
|
||||||
|
hostname={pendingNameHostname}
|
||||||
|
onSkip={() => {
|
||||||
|
const serverId = pendingNameHost.serverId;
|
||||||
|
setPendingNameHost(null);
|
||||||
|
returnToSource(serverId);
|
||||||
|
}}
|
||||||
|
onSave={(label) => {
|
||||||
|
const serverId = pendingNameHost.serverId;
|
||||||
|
void updateHost(serverId, { label }).finally(() => {
|
||||||
|
setPendingNameHost(null);
|
||||||
|
returnToSource(serverId);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
<Pressable onPress={() => router.back()}>
|
<Pressable onPress={closeToSource}>
|
||||||
<Text style={styles.headerButtonText}>Close</Text>
|
<Text style={styles.headerButtonText}>Close</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
+157
-212
@@ -5,16 +5,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TextInput,
|
TextInput,
|
||||||
Switch,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
Alert,
|
Alert,
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import Constants from "expo-constants";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Sun, Moon, Monitor } from "lucide-react-native";
|
import { Sun, Moon, Monitor, MoreVertical } from "lucide-react-native";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
@@ -26,9 +25,16 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||||
import { AddHostModal } from "@/components/add-host-modal";
|
import { AddHostModal } from "@/components/add-host-modal";
|
||||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||||
|
import { NameHostModal } from "@/components/name-host-modal";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
|
||||||
import { probeDaemonEndpoint } from "@/utils/test-daemon-connection";
|
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
@@ -110,6 +116,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
|
hostHeaderRight: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
hostLabel: {
|
hostLabel: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -147,44 +158,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
// Host actions
|
menuButton: {
|
||||||
hostActionsRow: {
|
width: 36,
|
||||||
flexDirection: "row",
|
height: 32,
|
||||||
alignItems: "center",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: theme.colors.border,
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
hostActionButton: {
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
hostActionText: {
|
menuButtonPressed: {
|
||||||
color: theme.colors.foregroundMuted,
|
backgroundColor: theme.colors.surface3,
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
},
|
||||||
hostActionPrimary: {
|
disabled: {
|
||||||
backgroundColor: theme.colors.palette.blue[500],
|
|
||||||
},
|
|
||||||
hostActionPrimaryText: {
|
|
||||||
color: theme.colors.palette.white,
|
|
||||||
},
|
|
||||||
hostActionDestructiveText: {
|
|
||||||
color: theme.colors.palette.red[500],
|
|
||||||
},
|
|
||||||
hostActionDisabled: {
|
|
||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
hostActionSeparator: {
|
|
||||||
width: 1,
|
|
||||||
height: 16,
|
|
||||||
backgroundColor: theme.colors.border,
|
|
||||||
marginHorizontal: theme.spacing[1],
|
|
||||||
},
|
|
||||||
testResultText: {
|
testResultText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
@@ -388,6 +374,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const params = useLocalSearchParams<{ editHost?: string }>();
|
||||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||||
const {
|
const {
|
||||||
daemons,
|
daemons,
|
||||||
@@ -395,19 +382,30 @@ export default function SettingsScreen() {
|
|||||||
updateHost,
|
updateHost,
|
||||||
removeHost,
|
removeHost,
|
||||||
removeConnection,
|
removeConnection,
|
||||||
upsertDirectConnection,
|
|
||||||
} = useDaemonRegistry();
|
} = useDaemonRegistry();
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||||
|
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(null);
|
||||||
|
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
||||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
const [newEndpointRaw, setNewEndpointRaw] = useState("");
|
|
||||||
const [isAddingConnection, setIsAddingConnection] = useState(false);
|
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const isLoading = settingsLoading || daemonLoading;
|
const isLoading = settingsLoading || daemonLoading;
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||||
|
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
||||||
|
},
|
||||||
|
[pendingNameHost]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -435,16 +433,42 @@ export default function SettingsScreen() {
|
|||||||
const handleEditDaemon = useCallback((profile: HostProfile) => {
|
const handleEditDaemon = useCallback((profile: HostProfile) => {
|
||||||
setEditingDaemon(profile);
|
setEditingDaemon(profile);
|
||||||
setEditLabel(profile.label ?? "");
|
setEditLabel(profile.label ?? "");
|
||||||
setNewEndpointRaw("");
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseEditDaemon = useCallback(() => {
|
const handleCloseEditDaemon = useCallback(() => {
|
||||||
if (isSavingEdit) return;
|
if (isSavingEdit) return;
|
||||||
setEditingDaemon(null);
|
setEditingDaemon(null);
|
||||||
setEditLabel("");
|
setEditLabel("");
|
||||||
setNewEndpointRaw("");
|
|
||||||
}, [isSavingEdit]);
|
}, [isSavingEdit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editHost = typeof params.editHost === "string" ? params.editHost.trim() : "";
|
||||||
|
if (!editHost) return;
|
||||||
|
if (lastHandledEditHostRef.current === editHost) return;
|
||||||
|
const profile = daemons.find((daemon) => daemon.serverId === editHost) ?? null;
|
||||||
|
if (!profile) return;
|
||||||
|
lastHandledEditHostRef.current = editHost;
|
||||||
|
handleEditDaemon(profile);
|
||||||
|
}, [daemons, handleEditDaemon, params.editHost]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingEditReopenServerId) return;
|
||||||
|
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
|
||||||
|
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
|
||||||
|
setPendingEditReopenServerId(null);
|
||||||
|
setAddConnectionTargetServerId(null);
|
||||||
|
if (profile) {
|
||||||
|
handleEditDaemon(profile);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
daemons,
|
||||||
|
handleEditDaemon,
|
||||||
|
isAddHostMethodVisible,
|
||||||
|
isDirectHostVisible,
|
||||||
|
isPasteLinkVisible,
|
||||||
|
pendingEditReopenServerId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSaveEditDaemon = useCallback(async () => {
|
const handleSaveEditDaemon = useCallback(async () => {
|
||||||
if (!editingDaemon) return;
|
if (!editingDaemon) return;
|
||||||
if (isSavingEdit) return;
|
if (isSavingEdit) return;
|
||||||
@@ -474,52 +498,6 @@ export default function SettingsScreen() {
|
|||||||
[removeConnection]
|
[removeConnection]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddDirectConnectionToHost = useCallback(async () => {
|
|
||||||
if (!editingDaemon) return;
|
|
||||||
if (isAddingConnection) return;
|
|
||||||
|
|
||||||
const raw = newEndpointRaw.trim();
|
|
||||||
if (!raw) {
|
|
||||||
Alert.alert("Host required", "Enter host:port.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (raw.includes("://") || raw.includes("/")) {
|
|
||||||
Alert.alert("Invalid host", "Enter host:port only (no ws://, no /ws).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint: string;
|
|
||||||
try {
|
|
||||||
endpoint = normalizeHostPort(raw);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Invalid host:port";
|
|
||||||
Alert.alert("Invalid host", message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsAddingConnection(true);
|
|
||||||
const { serverId } = await probeDaemonEndpoint(endpoint, { timeoutMs: 6000 });
|
|
||||||
if (serverId !== editingDaemon.serverId) {
|
|
||||||
Alert.alert(
|
|
||||||
"Wrong daemon",
|
|
||||||
`That endpoint belongs to ${serverId}, but this host is ${editingDaemon.serverId}.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await upsertDirectConnection({
|
|
||||||
serverId,
|
|
||||||
endpoint,
|
|
||||||
});
|
|
||||||
setNewEndpointRaw("");
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unable to add connection";
|
|
||||||
Alert.alert("Connection failed", message);
|
|
||||||
} finally {
|
|
||||||
setIsAddingConnection(false);
|
|
||||||
}
|
|
||||||
}, [editingDaemon, isAddingConnection, newEndpointRaw, upsertDirectConnection]);
|
|
||||||
|
|
||||||
const handleRemoveDaemon = useCallback(
|
const handleRemoveDaemon = useCallback(
|
||||||
(profile: HostProfile) => {
|
(profile: HostProfile) => {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
@@ -560,20 +538,6 @@ export default function SettingsScreen() {
|
|||||||
[removeHost]
|
[removeHost]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleUseSpeaker = useCallback(
|
|
||||||
(value: boolean) => {
|
|
||||||
void updateSettings({ ...settings, useSpeaker: value });
|
|
||||||
},
|
|
||||||
[settings, updateSettings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleKeepScreenOn = useCallback(
|
|
||||||
(value: boolean) => {
|
|
||||||
void updateSettings({ ...settings, keepScreenOn: value });
|
|
||||||
},
|
|
||||||
[settings, updateSettings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleThemeChange = useCallback(
|
const handleThemeChange = useCallback(
|
||||||
(newTheme: AppSettings["theme"]) => {
|
(newTheme: AppSettings["theme"]) => {
|
||||||
void updateSettings({ theme: newTheme });
|
void updateSettings({ theme: newTheme });
|
||||||
@@ -616,7 +580,7 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const restartConfirmationMessage =
|
const restartConfirmationMessage =
|
||||||
"This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts.";
|
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -665,7 +629,11 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
onPress={() => setIsAddHostMethodVisible(true)}
|
onPress={() => {
|
||||||
|
setAddConnectionTargetServerId(null);
|
||||||
|
setPendingEditReopenServerId(null);
|
||||||
|
setIsAddHostMethodVisible(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.addButtonText}>+ Add connection</Text>
|
<Text style={styles.addButtonText}>+ Add connection</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -676,25 +644,50 @@ export default function SettingsScreen() {
|
|||||||
onClose={() => setIsAddHostMethodVisible(false)}
|
onClose={() => setIsAddHostMethodVisible(false)}
|
||||||
onDirectConnection={() => setIsDirectHostVisible(true)}
|
onDirectConnection={() => setIsDirectHostVisible(true)}
|
||||||
onPasteLink={() => setIsPasteLinkVisible(true)}
|
onPasteLink={() => setIsPasteLinkVisible(true)}
|
||||||
onScanQr={() => router.push("/pair-scan")}
|
onScanQr={() => {
|
||||||
|
const target = addConnectionTargetServerId;
|
||||||
|
const source = target ? "editHost" : "settings";
|
||||||
|
const qs = target ? `?source=${source}&targetServerId=${encodeURIComponent(target)}` : `?source=${source}`;
|
||||||
|
router.push(`/pair-scan${qs}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddHostModal
|
<AddHostModal
|
||||||
visible={isDirectHostVisible}
|
visible={isDirectHostVisible}
|
||||||
|
targetServerId={addConnectionTargetServerId ?? undefined}
|
||||||
onClose={() => setIsDirectHostVisible(false)}
|
onClose={() => setIsDirectHostVisible(false)}
|
||||||
onSaved={(profile) => {
|
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId, hostname });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PairLinkModal
|
<PairLinkModal
|
||||||
visible={isPasteLinkVisible}
|
visible={isPasteLinkVisible}
|
||||||
|
targetServerId={addConnectionTargetServerId ?? undefined}
|
||||||
onClose={() => setIsPasteLinkVisible(false)}
|
onClose={() => setIsPasteLinkVisible(false)}
|
||||||
onSaved={(profile) => {
|
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId, hostname });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{pendingNameHost ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={pendingNameHost.serverId}
|
||||||
|
hostname={pendingNameHostname}
|
||||||
|
onSkip={() => setPendingNameHost(null)}
|
||||||
|
onSave={(label) => {
|
||||||
|
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
||||||
|
setPendingNameHost(null);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
title="Edit host"
|
title="Edit host"
|
||||||
visible={Boolean(editingDaemon)}
|
visible={Boolean(editingDaemon)}
|
||||||
@@ -756,28 +749,19 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
{editingDaemon ? (
|
{editingDaemon ? (
|
||||||
<View style={styles.formField}>
|
<View style={styles.formField}>
|
||||||
<Text style={styles.label}>Add direct connection</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.input, styles.inputUrl]}
|
|
||||||
value={newEndpointRaw}
|
|
||||||
onChangeText={setNewEndpointRaw}
|
|
||||||
placeholder="192.168.1.10:6767"
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={[styles.formButton, styles.formButtonPrimary, { alignSelf: "flex-start" }]}
|
||||||
styles.formButton,
|
onPress={() => {
|
||||||
styles.formButtonPrimary,
|
const serverId = editingDaemon.serverId;
|
||||||
(isAddingConnection || !newEndpointRaw.trim()) && styles.hostActionDisabled,
|
handleCloseEditDaemon();
|
||||||
{ alignSelf: "flex-end" },
|
setAddConnectionTargetServerId(serverId);
|
||||||
]}
|
setPendingEditReopenServerId(serverId);
|
||||||
onPress={() => void handleAddDirectConnectionToHost()}
|
setIsAddHostMethodVisible(true);
|
||||||
disabled={isAddingConnection || !newEndpointRaw.trim()}
|
}}
|
||||||
|
testID="edit-host-add-connection"
|
||||||
>
|
>
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
|
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
|
||||||
{isAddingConnection ? "Adding..." : "Add"}
|
Add connection
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -785,14 +769,14 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<View style={styles.formActionsRow}>
|
<View style={styles.formActionsRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.formButton, isSavingEdit && styles.hostActionDisabled]}
|
style={[styles.formButton, isSavingEdit && styles.disabled]}
|
||||||
onPress={handleCloseEditDaemon}
|
onPress={handleCloseEditDaemon}
|
||||||
disabled={isSavingEdit}
|
disabled={isSavingEdit}
|
||||||
>
|
>
|
||||||
<Text style={styles.formButtonText}>Cancel</Text>
|
<Text style={styles.formButtonText}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.formButton, styles.formButtonPrimary, isSavingEdit && styles.hostActionDisabled]}
|
style={[styles.formButton, styles.formButtonPrimary, isSavingEdit && styles.disabled]}
|
||||||
onPress={() => void handleSaveEditDaemon()}
|
onPress={() => void handleSaveEditDaemon()}
|
||||||
disabled={isSavingEdit}
|
disabled={isSavingEdit}
|
||||||
>
|
>
|
||||||
@@ -846,66 +830,11 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Audio Settings */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Audio</Text>
|
|
||||||
|
|
||||||
<View style={styles.audioCard}>
|
|
||||||
<View style={styles.audioRow}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>Use speaker</Text>
|
|
||||||
<Text style={styles.audioRowDescription}>
|
|
||||||
Play audio through speaker instead of earpiece
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.useSpeaker}
|
|
||||||
onValueChange={handleToggleUseSpeaker}
|
|
||||||
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
|
|
||||||
thumbColor={settings.useSpeaker ? defaultTheme.colors.palette.white : defaultTheme.colors.palette.gray[300]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>Keep screen on</Text>
|
|
||||||
<Text style={styles.audioRowDescription}>
|
|
||||||
Prevent screen from sleeping during voice sessions
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.keepScreenOn}
|
|
||||||
onValueChange={handleToggleKeepScreenOn}
|
|
||||||
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
|
|
||||||
thumbColor={settings.keepScreenOn ? defaultTheme.colors.palette.white : defaultTheme.colors.palette.gray[300]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Developer */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Developer</Text>
|
|
||||||
<View style={styles.devCard}>
|
|
||||||
<Pressable
|
|
||||||
style={styles.devButton}
|
|
||||||
onPress={() => router.push("/audio-test")}
|
|
||||||
>
|
|
||||||
<View style={styles.devButtonContent}>
|
|
||||||
<Text style={styles.devButtonTitle}>Audio test</Text>
|
|
||||||
<Text style={styles.devButtonDescription}>
|
|
||||||
Test audio recording and playback
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.footerAppInfo}>
|
<View style={styles.footerAppInfo}>
|
||||||
<Text style={styles.footerText}>Voice Dev Mobile</Text>
|
<Text style={styles.footerText}>Paseo</Text>
|
||||||
<Text style={styles.footerVersion}>Version 1.0.0</Text>
|
<Text style={styles.footerVersion}>Version {appVersion}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.resetButton} onPress={handleReset}>
|
<Pressable style={styles.resetButton} onPress={handleReset}>
|
||||||
<Text style={styles.resetButtonText}>Reset to defaults</Text>
|
<Text style={styles.resetButtonText}>Reset to defaults</Text>
|
||||||
@@ -1080,9 +1009,45 @@ function DaemonCard({
|
|||||||
<View style={styles.hostCardContent}>
|
<View style={styles.hostCardContent}>
|
||||||
<View style={styles.hostHeaderRow}>
|
<View style={styles.hostHeaderRow}>
|
||||||
<Text style={styles.hostLabel}>{daemon.label}</Text>
|
<Text style={styles.hostLabel}>{daemon.label}</Text>
|
||||||
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
|
<View style={styles.hostHeaderRight}>
|
||||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
|
||||||
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||||
|
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID={`daemon-menu-trigger-${daemon.serverId}`}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.menuButton,
|
||||||
|
pressed ? styles.menuButtonPressed : null,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Host actions for ${daemon.label}`}
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220} testID={`daemon-menu-content-${daemon.serverId}`}>
|
||||||
|
<DropdownMenuItem onSelect={() => onEdit(daemon)} testID={`daemon-menu-edit-${daemon.serverId}`}>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem destructive onSelect={() => onRemove(daemon)} testID={`daemon-menu-remove-${daemon.serverId}`}>
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>Advanced</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={handleRestartPress}
|
||||||
|
status={isRestarting ? "pending" : "idle"}
|
||||||
|
pendingLabel="Restarting..."
|
||||||
|
disabled={!daemonClient || !isConnectedRef.current}
|
||||||
|
testID={`daemon-menu-restart-${daemon.serverId}`}
|
||||||
|
>
|
||||||
|
Restart daemon
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.hostUrl}>
|
<Text style={styles.hostUrl}>
|
||||||
@@ -1113,26 +1078,6 @@ function DaemonCard({
|
|||||||
{connectionsSummary ? <Text style={styles.hostConnections}>{connectionsSummary}</Text> : null}
|
{connectionsSummary ? <Text style={styles.hostConnections}>{connectionsSummary}</Text> : null}
|
||||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.hostActionsRow}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.hostActionButton, isRestarting && styles.hostActionDisabled]}
|
|
||||||
onPress={handleRestartPress}
|
|
||||||
disabled={isRestarting}
|
|
||||||
>
|
|
||||||
{isRestarting ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.hostActionText}>Restart</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={styles.hostActionButton} onPress={() => onEdit(daemon)}>
|
|
||||||
<Text style={styles.hostActionText}>Edit</Text>
|
|
||||||
</Pressable>
|
|
||||||
<View style={styles.hostActionSeparator} />
|
|
||||||
<Pressable style={styles.hostActionButton} onPress={() => onRemove(daemon)}>
|
|
||||||
<Text style={[styles.hostActionText, styles.hostActionDestructiveText]}>Remove</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,27 +146,25 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
|
|||||||
export interface AddHostModalProps {
|
export interface AddHostModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved?: (profile: HostProfile) => void;
|
targetServerId?: string;
|
||||||
|
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddHostModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { upsertDirectConnection } = useDaemonRegistry();
|
const { daemons, upsertDirectConnection } = useDaemonRegistry();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||||
const labelInputRef = useRef<TextInput>(null);
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [endpointRaw, setEndpointRaw] = useState("");
|
const [endpointRaw, setEndpointRaw] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setLabel("");
|
|
||||||
setEndpointRaw("");
|
setEndpointRaw("");
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
onClose();
|
onClose();
|
||||||
@@ -198,14 +196,23 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
|
|
||||||
const { serverId } = await probeDaemonEndpoint(endpoint);
|
const { serverId, hostname } = await probeDaemonEndpoint(endpoint);
|
||||||
|
if (targetServerId && serverId !== targetServerId) {
|
||||||
|
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
||||||
|
setErrorMessage(message);
|
||||||
|
if (!isMobile) {
|
||||||
|
Alert.alert("Wrong daemon", message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
endpoint,
|
endpoint,
|
||||||
label: label.trim(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onSaved?.(profile);
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
|
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
|
||||||
@@ -223,30 +230,12 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [endpointRaw, handleClose, isMobile, isSaving, label, onSaved, upsertDirectConnection]);
|
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||||
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
|
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
|
||||||
<Text style={styles.label}>Label (optional)</Text>
|
|
||||||
<InputComponent
|
|
||||||
ref={labelInputRef as any}
|
|
||||||
value={label}
|
|
||||||
onChangeText={setLabel}
|
|
||||||
placeholder="My Host"
|
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={styles.input}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
editable={!isSaving}
|
|
||||||
returnKeyType="next"
|
|
||||||
blurOnSubmit={false}
|
|
||||||
onSubmitEditing={() => hostInputRef.current?.focus()}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Host</Text>
|
<Text style={styles.label}>Host</Text>
|
||||||
<InputComponent
|
<InputComponent
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Pressable, Text, TextInput, View } from "react-native";
|
||||||
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
helper: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
marginTop: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
marginTop: theme.spacing[4],
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: theme.colors.palette.white,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface NameHostModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
onSkip: () => void;
|
||||||
|
onSave: (label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||||
|
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const hasEditedRef = useRef(false);
|
||||||
|
|
||||||
|
const suggested = (hostname?.trim() || serverId).trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setLabel(suggested);
|
||||||
|
hasEditedRef.current = false;
|
||||||
|
}, [suggested, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (hasEditedRef.current) return;
|
||||||
|
if (!hostname) return;
|
||||||
|
const trimmed = label.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed === serverId) {
|
||||||
|
setLabel(hostname.trim());
|
||||||
|
}
|
||||||
|
}, [hostname, label, serverId, visible]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((value: string) => {
|
||||||
|
hasEditedRef.current = true;
|
||||||
|
setLabel(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const trimmed = label.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onSkip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(trimmed);
|
||||||
|
}, [label, onSave, onSkip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
|
||||||
|
<Text style={styles.helper}>
|
||||||
|
Optional. You can rename this later in Settings.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Label</Text>
|
||||||
|
<InputComponent
|
||||||
|
value={label}
|
||||||
|
onChangeText={handleChange}
|
||||||
|
placeholder={suggested}
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleSave}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={styles.button} onPress={onSkip} testID="name-host-skip">
|
||||||
|
<Text style={styles.buttonText}>Skip</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={[styles.button, styles.primaryButton]} onPress={handleSave} testID="name-host-save">
|
||||||
|
<Text style={[styles.buttonText, styles.primaryButtonText]}>Save</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
|||||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
|
import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints";
|
||||||
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -66,12 +68,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
export interface PairLinkModalProps {
|
export interface PairLinkModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved?: (profile: HostProfile) => void;
|
targetServerId?: string;
|
||||||
|
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) {
|
export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: PairLinkModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||||
@@ -99,11 +102,44 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedOffer = (() => {
|
||||||
|
try {
|
||||||
|
const idx = raw.indexOf("#offer=");
|
||||||
|
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||||
|
if (!encoded) {
|
||||||
|
throw new Error("Offer payload is empty");
|
||||||
|
}
|
||||||
|
const payload = decodeOfferFragmentPayload(encoded);
|
||||||
|
return ConnectionOfferSchema.parse(payload);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid pairing link";
|
||||||
|
setErrorMessage(message);
|
||||||
|
if (!isMobile) {
|
||||||
|
Alert.alert("Pairing failed", message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!parsedOffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetServerId && parsedOffer.serverId !== targetServerId) {
|
||||||
|
const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`;
|
||||||
|
setErrorMessage(message);
|
||||||
|
if (!isMobile) {
|
||||||
|
Alert.alert("Wrong daemon", message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||||
onSaved?.(profile);
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: null, isNewHost });
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||||
@@ -114,7 +150,7 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]);
|
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
|
import { NameHostModal } from "./name-host-modal";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -70,8 +73,27 @@ export interface WelcomeScreenProps {
|
|||||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { updateHost } = useDaemonRegistry();
|
||||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
||||||
|
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
||||||
|
},
|
||||||
|
[pendingNameHost]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishOnboarding = useCallback(
|
||||||
|
(serverId: string) => {
|
||||||
|
router.replace({ pathname: "/", params: { serverId } });
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -110,7 +132,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
{Platform.OS !== "web" ? (
|
{Platform.OS !== "web" ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.actionButton}
|
style={styles.actionButton}
|
||||||
onPress={() => router.push("/pair-scan")}
|
onPress={() => router.push("/pair-scan?source=onboarding")}
|
||||||
testID="welcome-scan-qr"
|
testID="welcome-scan-qr"
|
||||||
>
|
>
|
||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
@@ -122,20 +144,52 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<AddHostModal
|
<AddHostModal
|
||||||
visible={isDirectOpen}
|
visible={isDirectOpen}
|
||||||
onClose={() => setIsDirectOpen(false)}
|
onClose={() => setIsDirectOpen(false)}
|
||||||
onSaved={(profile) => {
|
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||||
onHostAdded?.(profile);
|
onHostAdded?.(profile);
|
||||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
setPendingRedirectServerId(serverId);
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId, hostname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PairLinkModal
|
<PairLinkModal
|
||||||
visible={isPasteLinkOpen}
|
visible={isPasteLinkOpen}
|
||||||
onClose={() => setIsPasteLinkOpen(false)}
|
onClose={() => setIsPasteLinkOpen(false)}
|
||||||
onSaved={(profile) => {
|
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||||
onHostAdded?.(profile);
|
onHostAdded?.(profile);
|
||||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
setPendingRedirectServerId(serverId);
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId, hostname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{pendingNameHost && pendingRedirectServerId ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={pendingNameHost.serverId}
|
||||||
|
hostname={pendingNameHostname}
|
||||||
|
onSkip={() => {
|
||||||
|
const serverId = pendingRedirectServerId;
|
||||||
|
setPendingNameHost(null);
|
||||||
|
setPendingRedirectServerId(null);
|
||||||
|
finishOnboarding(serverId);
|
||||||
|
}}
|
||||||
|
onSave={(label) => {
|
||||||
|
const serverId = pendingRedirectServerId;
|
||||||
|
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
||||||
|
setPendingNameHost(null);
|
||||||
|
setPendingRedirectServerId(null);
|
||||||
|
finishOnboarding(serverId);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
decodeOfferFragmentPayload,
|
decodeOfferFragmentPayload,
|
||||||
deriveLabelFromEndpoint,
|
|
||||||
normalizeHostPort,
|
normalizeHostPort,
|
||||||
} from "@/utils/daemon-endpoints";
|
} from "@/utils/daemon-endpoints";
|
||||||
import {
|
import {
|
||||||
@@ -163,11 +162,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labelTrimmed = input.label?.trim() ?? "";
|
const labelTrimmed = input.label?.trim() ?? "";
|
||||||
const derivedLabel =
|
const derivedLabel = labelTrimmed || serverId;
|
||||||
labelTrimmed ||
|
|
||||||
(input.connection.type === "direct"
|
|
||||||
? deriveLabelFromEndpoint(input.connection.endpoint)
|
|
||||||
: serverId);
|
|
||||||
|
|
||||||
const idx = existing.findIndex((d) => d.serverId === serverId);
|
const idx = existing.findIndex((d) => d.serverId === serverId);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
@@ -252,7 +247,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
serverId: offer.serverId,
|
serverId: offer.serverId,
|
||||||
relayEndpoint: offer.relay.endpoint,
|
relayEndpoint: offer.relay.endpoint,
|
||||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
label: offer.serverId,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[upsertRelayConnection]
|
[upsertRelayConnection]
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function SessionProvider({
|
|||||||
const updateSessionConnection = useSessionStore(
|
const updateSessionConnection = useSessionStore(
|
||||||
(state) => state.updateSessionConnection
|
(state) => state.updateSessionConnection
|
||||||
);
|
);
|
||||||
|
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||||
|
|
||||||
// Track focused agent for heartbeat
|
// Track focused agent for heartbeat
|
||||||
const focusedAgentId = useSessionStore(
|
const focusedAgentId = useSessionStore(
|
||||||
@@ -972,6 +973,17 @@ export function SessionProvider({
|
|||||||
const unsubStatus = client.on("status", (message) => {
|
const unsubStatus = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const status = message.payload.status;
|
const status = message.payload.status;
|
||||||
|
if (status === "server_info") {
|
||||||
|
const payload = message.payload as any;
|
||||||
|
const rawServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
||||||
|
if (!rawServerId) return;
|
||||||
|
const rawHostname = typeof payload.hostname === "string" ? payload.hostname.trim() : "";
|
||||||
|
updateSessionServerInfo(serverId, {
|
||||||
|
serverId: rawServerId,
|
||||||
|
hostname: rawHostname.length > 0 ? rawHostname : null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status === "agent_initialized" && "agentId" in message.payload) {
|
if (status === "agent_initialized" && "agentId" in message.payload) {
|
||||||
console.log("[Session] status agent_initialized", {
|
console.log("[Session] status agent_initialized", {
|
||||||
agentId: (message.payload as any).agentId,
|
agentId: (message.payload as any).agentId,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type { SessionState } from "@/stores/session-store";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { randomUUID } from "expo-crypto";
|
import { randomUUID } from "expo-crypto";
|
||||||
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
|
|
||||||
const VOICE_CONVERSATION_ID_STORAGE_KEY = "@paseo:voice-conversation-id";
|
const VOICE_CONVERSATION_ID_STORAGE_KEY = "@paseo:voice-conversation-id";
|
||||||
|
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||||
|
|
||||||
interface VoiceContextValue {
|
interface VoiceContextValue {
|
||||||
isVoiceMode: boolean;
|
isVoiceMode: boolean;
|
||||||
@@ -155,6 +157,9 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
try {
|
try {
|
||||||
realtimeSessionRef.current = session;
|
realtimeSessionRef.current = session;
|
||||||
setActiveServerId(serverId);
|
setActiveServerId(serverId);
|
||||||
|
await activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch((error) => {
|
||||||
|
console.warn("[Voice] Failed to activate keep-awake:", error);
|
||||||
|
});
|
||||||
await session.audioPlayer?.warmup?.();
|
await session.audioPlayer?.warmup?.();
|
||||||
await realtimeAudio.start();
|
await realtimeAudio.start();
|
||||||
setIsVoiceMode(true);
|
setIsVoiceMode(true);
|
||||||
@@ -177,6 +182,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Voice] Failed to start:", error);
|
console.error("[Voice] Failed to start:", error);
|
||||||
setActiveServerId((current) => (current === serverId ? null : current));
|
setActiveServerId((current) => (current === serverId ? null : current));
|
||||||
|
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -190,6 +196,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
await realtimeAudio.stop();
|
await realtimeAudio.stop();
|
||||||
setIsVoiceMode(false);
|
setIsVoiceMode(false);
|
||||||
setActiveServerId(null);
|
setActiveServerId(null);
|
||||||
|
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
|
||||||
console.log("[Voice] Mode disabled");
|
console.log("[Voice] Mode disabled");
|
||||||
|
|
||||||
if (session?.client) {
|
if (session?.client) {
|
||||||
@@ -199,6 +206,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Voice] Failed to stop:", error);
|
console.error("[Voice] Failed to stop:", error);
|
||||||
|
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [realtimeAudio]);
|
}, [realtimeAudio]);
|
||||||
|
|||||||
@@ -7,14 +7,10 @@ const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
|||||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
useSpeaker: boolean;
|
|
||||||
keepScreenOn: boolean;
|
|
||||||
theme: "dark" | "light" | "auto";
|
theme: "dark" | "light" | "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||||
useSpeaker: true,
|
|
||||||
keepScreenOn: true,
|
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,12 +95,6 @@ async function loadSettingsFromStorage(): Promise<AppSettings> {
|
|||||||
|
|
||||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||||
const result: Partial<AppSettings> = {};
|
const result: Partial<AppSettings> = {};
|
||||||
if (typeof legacy.useSpeaker === "boolean") {
|
|
||||||
result.useSpeaker = legacy.useSpeaker;
|
|
||||||
}
|
|
||||||
if (typeof legacy.keepScreenOn === "boolean") {
|
|
||||||
result.keepScreenOn = legacy.keepScreenOn;
|
|
||||||
}
|
|
||||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||||
result.theme = legacy.theme;
|
result.theme = legacy.theme;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ export interface DaemonConnectionSnapshot {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DaemonServerInfo = {
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// Per-session state
|
// Per-session state
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -156,6 +161,9 @@ export interface SessionState {
|
|||||||
// Connection snapshot (mutable)
|
// Connection snapshot (mutable)
|
||||||
connection: DaemonConnectionSnapshot;
|
connection: DaemonConnectionSnapshot;
|
||||||
|
|
||||||
|
// Server metadata (from server_info handshake)
|
||||||
|
serverInfo: DaemonServerInfo | null;
|
||||||
|
|
||||||
// Audio player (immutable reference)
|
// Audio player (immutable reference)
|
||||||
audioPlayer: ReturnType<typeof useAudioPlayer> | null;
|
audioPlayer: ReturnType<typeof useAudioPlayer> | null;
|
||||||
|
|
||||||
@@ -211,6 +219,7 @@ interface SessionStoreActions {
|
|||||||
getSession: (serverId: string) => SessionState | undefined;
|
getSession: (serverId: string) => SessionState | undefined;
|
||||||
updateSessionClient: (serverId: string, client: DaemonClient) => void;
|
updateSessionClient: (serverId: string, client: DaemonClient) => void;
|
||||||
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
|
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
|
||||||
|
updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void;
|
||||||
|
|
||||||
// Audio state
|
// Audio state
|
||||||
setIsPlayingAudio: (serverId: string, playing: boolean) => void;
|
setIsPlayingAudio: (serverId: string, playing: boolean) => void;
|
||||||
@@ -299,6 +308,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
|||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
connection: createDefaultConnectionSnapshot(client),
|
connection: createDefaultConnectionSnapshot(client),
|
||||||
|
serverInfo: null,
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
hasHydratedAgents: false,
|
hasHydratedAgents: false,
|
||||||
isPlayingAudio: false,
|
isPlayingAudio: false,
|
||||||
@@ -408,6 +418,38 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateSessionServerInfo: (serverId, info) => {
|
||||||
|
set((prev) => {
|
||||||
|
const session = prev.sessions[serverId];
|
||||||
|
if (!session) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHostname = info.hostname?.trim() || null;
|
||||||
|
const prevHostname = session.serverInfo?.hostname?.trim() || null;
|
||||||
|
|
||||||
|
if (session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSessionStoreUpdate("updateSessionServerInfo", serverId, {
|
||||||
|
serverId: info.serverId,
|
||||||
|
hostname: nextHostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
sessions: {
|
||||||
|
...prev.sessions,
|
||||||
|
[serverId]: {
|
||||||
|
...session,
|
||||||
|
serverInfo: { serverId: info.serverId, hostname: nextHostname },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getSession: (serverId) => {
|
getSession: (serverId) => {
|
||||||
return get().sessions[serverId];
|
return get().sessions[serverId];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class DaemonConnectionTestError extends Error {
|
|||||||
export async function probeDaemonEndpoint(
|
export async function probeDaemonEndpoint(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: { timeoutMs?: number }
|
options?: { timeoutMs?: number }
|
||||||
): Promise<{ serverId: string }> {
|
): Promise<{ serverId: string; hostname: string | null }> {
|
||||||
const timeoutMs = options?.timeoutMs ?? 6000;
|
const timeoutMs = options?.timeoutMs ?? 6000;
|
||||||
const url = buildDaemonWebSocketUrl(endpoint);
|
const url = buildDaemonWebSocketUrl(endpoint);
|
||||||
|
|
||||||
@@ -51,12 +51,12 @@ export async function probeDaemonEndpoint(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await new Promise<{ serverId: string }>((resolve, reject) => {
|
return await new Promise<{ serverId: string; hostname: string | null }>((resolve, reject) => {
|
||||||
let cleanedUp = false;
|
let cleanedUp = false;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
let unsubscribeStatus: (() => void) | null = null;
|
let unsubscribeStatus: (() => void) | null = null;
|
||||||
let isConnected = false;
|
|
||||||
let serverId: string | null = null;
|
let serverId: string | null = null;
|
||||||
|
let hostname: string | null = null;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (cleanedUp) return;
|
if (cleanedUp) return;
|
||||||
@@ -67,13 +67,13 @@ export async function probeDaemonEndpoint(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const maybeFinishOk = () => {
|
const maybeFinishOk = () => {
|
||||||
if (!isConnected) return;
|
|
||||||
if (!serverId) return;
|
if (!serverId) return;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve({ serverId });
|
resolve({ serverId, hostname });
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishErr = (error: Error) => {
|
const finishErr = (error: Error) => {
|
||||||
|
if (cleanedUp) return;
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
@@ -88,11 +88,6 @@ export async function probeDaemonEndpoint(
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
unsubscribe = client.subscribeConnectionStatus((state) => {
|
unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||||
if (state.status === "connected") {
|
|
||||||
isConnected = true;
|
|
||||||
maybeFinishOk();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.status === "disconnected") {
|
if (state.status === "disconnected") {
|
||||||
const reason = normalizeNonEmptyString(state.reason);
|
const reason = normalizeNonEmptyString(state.reason);
|
||||||
const lastError = normalizeNonEmptyString(client.lastError);
|
const lastError = normalizeNonEmptyString(client.lastError);
|
||||||
@@ -103,11 +98,15 @@ export async function probeDaemonEndpoint(
|
|||||||
|
|
||||||
unsubscribeStatus = client.on("status", (message) => {
|
unsubscribeStatus = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const payload = message.payload as { status?: unknown; serverId?: unknown };
|
const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown };
|
||||||
if (payload?.status !== "server_info") return;
|
if (payload?.status !== "server_info") return;
|
||||||
const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
serverId = raw;
|
serverId = raw;
|
||||||
|
hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null;
|
||||||
|
if (hostname && hostname.length === 0) {
|
||||||
|
hostname = null;
|
||||||
|
}
|
||||||
maybeFinishOk();
|
maybeFinishOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { WebSocketServer } from "ws";
|
|||||||
import type { Server as HTTPServer } from "http";
|
import type { Server as HTTPServer } from "http";
|
||||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { hostname as getHostname } from "node:os";
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
import type { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
@@ -238,6 +239,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
payload: {
|
payload: {
|
||||||
status: "server_info",
|
status: "server_info",
|
||||||
serverId: this.serverId,
|
serverId: this.serverId,
|
||||||
|
hostname: getHostname(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user