@@ -32,10 +32,12 @@ import androidx.compose.runtime.remember
3232import androidx.compose.runtime.rememberCoroutineScope
3333import androidx.compose.runtime.setValue
3434import androidx.compose.ui.Modifier
35+ import androidx.compose.ui.text.input.KeyboardType
3536import androidx.compose.ui.unit.dp
3637import com.whispertranscriber.data.AppSettings
3738import com.whispertranscriber.data.SettingsStore
3839import com.whispertranscriber.network.WhisperServerDiscovery
40+ import androidx.compose.foundation.text.KeyboardOptions
3941import kotlinx.coroutines.Job
4042import kotlinx.coroutines.delay
4143import kotlinx.coroutines.launch
@@ -51,18 +53,33 @@ fun SettingsScreen(
5153
5254 // Local state for the text field, seeded once from persisted value
5355 var serverUrl by remember { mutableStateOf<String ?>(null ) }
56+ var serverPortText by remember { mutableStateOf<String ?>(null ) }
5457 var debounceJob by remember { mutableStateOf<Job ?>(null ) }
55- var discoveryStatus by remember { mutableStateOf(" Leave blank to auto-discover port 8090." ) }
58+ var portDebounceJob by remember { mutableStateOf<Job ?>(null ) }
59+ var discoveryStatus by remember { mutableStateOf(" Leave blank to auto-discover healthy WhisperLiveKit servers." ) }
5660 var discovering by remember { mutableStateOf(false ) }
61+ var discoveredServers by remember { mutableStateOf<List <String >>(emptyList()) }
62+ var serverDropdownExpanded by remember { mutableStateOf(false ) }
5763
5864 // Seed local state from DataStore only on first real emission
5965 LaunchedEffect (settings.whisperServerUrl) {
6066 if (serverUrl == null ) {
6167 serverUrl = settings.whisperServerUrl
6268 }
6369 }
70+ LaunchedEffect (settings.whisperServerPort) {
71+ if (serverPortText == null ) {
72+ serverPortText = settings.whisperServerPort.toString()
73+ }
74+ }
6475
6576 val displayUrl = serverUrl ? : settings.whisperServerUrl
77+ val displayPort = serverPortText ? : settings.whisperServerPort.toString()
78+ val selectedServerOption = if (displayUrl.isNotBlank() && displayUrl in discoveredServers) {
79+ displayUrl
80+ } else {
81+ " Custom"
82+ }
6683
6784 Scaffold (
6885 topBar = {
@@ -88,6 +105,48 @@ fun SettingsScreen(
88105 Text (" WhisperLiveKit Server" , style = MaterialTheme .typography.titleMedium)
89106 Spacer (Modifier .height(8 .dp))
90107
108+ ExposedDropdownMenuBox (
109+ expanded = serverDropdownExpanded,
110+ onExpandedChange = { serverDropdownExpanded = it }
111+ ) {
112+ OutlinedTextField (
113+ value = selectedServerOption,
114+ onValueChange = {},
115+ readOnly = true ,
116+ label = { Text (" Discovered Servers" ) },
117+ trailingIcon = { ExposedDropdownMenuDefaults .TrailingIcon (expanded = serverDropdownExpanded) },
118+ modifier = Modifier
119+ .fillMaxWidth()
120+ .menuAnchor()
121+ )
122+ ExposedDropdownMenu (
123+ expanded = serverDropdownExpanded,
124+ onDismissRequest = { serverDropdownExpanded = false }
125+ ) {
126+ DropdownMenuItem (
127+ text = { Text (" Custom" ) },
128+ onClick = {
129+ if (selectedServerOption != " Custom" ) {
130+ serverUrl = " "
131+ scope.launch { settingsStore.updateServerUrl(" " ) }
132+ }
133+ serverDropdownExpanded = false
134+ }
135+ )
136+ discoveredServers.forEach { discoveredUrl ->
137+ DropdownMenuItem (
138+ text = { Text (discoveredUrl) },
139+ onClick = {
140+ serverUrl = discoveredUrl
141+ scope.launch { settingsStore.updateServerUrl(discoveredUrl) }
142+ serverDropdownExpanded = false
143+ }
144+ )
145+ }
146+ }
147+ }
148+
149+ Spacer (Modifier .height(8 .dp))
91150 OutlinedTextField (
92151 value = displayUrl,
93152 onValueChange = { newValue ->
@@ -98,25 +157,52 @@ fun SettingsScreen(
98157 settingsStore.updateServerUrl(newValue)
99158 }
100159 },
101- label = { Text (" Server URL" ) },
102- placeholder = { Text (" Auto-discover http://*:8090" ) },
160+ label = { Text (" Custom Server URL" ) },
161+ placeholder = { Text (" Leave blank to auto-discover" ) },
162+ modifier = Modifier .fillMaxWidth(),
163+ singleLine = true
164+ )
165+
166+ Spacer (Modifier .height(8 .dp))
167+ OutlinedTextField (
168+ value = displayPort,
169+ onValueChange = { rawValue ->
170+ val newValue = rawValue.filter { it.isDigit() }.take(5 )
171+ serverPortText = newValue
172+ portDebounceJob?.cancel()
173+ val port = newValue.toIntOrNull()
174+ if (port != null && port in 1 .. 65535 ) {
175+ portDebounceJob = scope.launch {
176+ delay(500 )
177+ settingsStore.updateServerPort(port)
178+ }
179+ }
180+ },
181+ label = { Text (" Discovery Port" ) },
182+ placeholder = { Text (WhisperServerDiscovery .DEFAULT_PORT .toString()) },
103183 modifier = Modifier .fillMaxWidth(),
184+ keyboardOptions = KeyboardOptions (keyboardType = KeyboardType .Number ),
104185 singleLine = true
105186 )
106187
107188 Spacer (Modifier .height(8 .dp))
108189 Button (
109190 onClick = {
191+ val port = displayPort.toIntOrNull()?.takeIf { it in 1 .. 65535 }
192+ ? : WhisperServerDiscovery .DEFAULT_PORT
110193 discovering = true
111- discoveryStatus = " Scanning local networks and Tailscale..."
194+ discoveryStatus = " Scanning local networks and Tailscale on port $port ..."
112195 scope.launch {
113- val discovered = WhisperServerDiscovery .discover()
114- if (discovered == null ) {
115- discoveryStatus = " No WhisperLiveKit server found on port 8090."
196+ settingsStore.updateServerPort(port)
197+ val discovered = WhisperServerDiscovery .discoverAll(port = port)
198+ discoveredServers = discovered.map { it.url }
199+ if (discoveredServers.isEmpty()) {
200+ discoveryStatus = " No healthy WhisperLiveKit server found on port $port ."
116201 } else {
117- serverUrl = discovered.url
118- settingsStore.updateServerUrl(discovered.url)
119- discoveryStatus = " Found ${discovered.url} "
202+ val selectedUrl = discoveredServers.first()
203+ serverUrl = selectedUrl
204+ settingsStore.updateServerUrl(selectedUrl)
205+ discoveryStatus = " Found ${discoveredServers.size} server(s)."
120206 }
121207 discovering = false
122208 }
0 commit comments