polish(android): grouped Settings cards + ConnectScreen error banner & search indicator
apple / swift (push) Successful in 54s
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled

- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
  cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
  (was plain red text lost in the layout), and a "Searching the local network..."
  spinner appears while discovery is active but nothing's turned up yet.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:58:35 +00:00
parent f39230e8f4
commit b9e50faa40
2 changed files with 133 additions and 61 deletions
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -40,6 +41,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@@ -228,12 +230,21 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
) )
} }
} else { } else {
Text( // Result/error: a filled error container reads as a real failure banner,
it, // not just red text lost in the layout.
style = MaterialTheme.typography.bodySmall, Surface(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.errorContainer,
textAlign = TextAlign.Center, shape = MaterialTheme.shapes.medium,
) modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
@@ -282,6 +293,26 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
} }
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
// working rather than looking idle/empty.
if (nearbyGranted && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(96.dp)) Spacer(Modifier.height(96.dp))
} }
@@ -7,6 +7,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,6 +20,7 @@ import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -34,8 +36,9 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
/** /**
* Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect * Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
* screen. Resolution/refresh "Native" resolve from the device display at connect time. * immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
* resolve from the device display at connect time.
*/ */
@Composable @Composable
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) { fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
@@ -48,58 +51,62 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
val micLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> update(s.copy(micEnabled = granted)) }
Column( Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(16.dp), .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
Text("Settings", style = MaterialTheme.typography.headlineMedium) Text("Settings", style = MaterialTheme.typography.headlineMedium)
val (nw, nh, nhz) = nativeDisplayMode(context) val (nw, nh, nhz) = nativeDisplayMode(context)
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
},
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
SettingDropdown( SettingsGroup("Display") {
label = "Refresh rate", SettingDropdown(
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl (${nhz} Hz)" else lbl) }, label = "Resolution",
selected = s.hz, options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
) { hz -> update(s.copy(hz = hz)) } (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
},
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
SettingDropdown( SettingDropdown(
label = "Bitrate", label = "Refresh rate",
options = BITRATE_OPTIONS, options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
selected = s.bitrateKbps, selected = s.hz,
) { kbps -> update(s.copy(bitrateKbps = kbps)) } ) { hz -> update(s.copy(hz = hz)) }
SettingDropdown( SettingDropdown(
label = "Compositor (virtual-display host backend)", label = "Bitrate",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, options = BITRATE_OPTIONS,
selected = s.compositor, selected = s.bitrateKbps,
) { c -> update(s.copy(compositor = c)) } ) { kbps -> update(s.copy(bitrateKbps = kbps)) }
}
SettingDropdown( SettingsGroup("Host") {
label = "Controller type", SettingDropdown(
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, label = "Compositor",
selected = s.gamepad, options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
) { g -> update(s.copy(gamepad = g)) } selected = s.compositor,
) { c -> update(s.copy(compositor = c)) }
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. SettingDropdown(
val micLauncher = rememberLauncherForActivityResult( label = "Controller type",
ActivityResultContracts.RequestPermission(), options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
) { granted -> update(s.copy(micEnabled = granted)) } selected = s.gamepad,
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { ) { g -> update(s.copy(gamepad = g)) }
Column(Modifier.weight(1f)) { }
Text("Microphone", style = MaterialTheme.typography.bodyLarge)
Text( SettingsGroup("Audio") {
"Send your mic to the host's virtual microphone", ToggleRow(
style = MaterialTheme.typography.bodySmall, title = "Microphone",
) subtitle = "Send your mic to the host's virtual microphone",
}
Switch(
checked = s.micEnabled, checked = s.micEnabled,
onCheckedChange = { on -> onCheckedChange = { on ->
when { when {
@@ -112,17 +119,10 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
) )
} }
// Live stats overlay (FPS / throughput / capture→client latency). A 3-finger tap in-stream SettingsGroup("Overlay") {
// toggles it without coming back here. ToggleRow(
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { title = "Stats overlay",
Column(Modifier.weight(1f)) { subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
Text("Stats overlay", style = MaterialTheme.typography.bodyLarge)
Text(
"Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = s.statsHudEnabled, checked = s.statsHudEnabled,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) }, onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
) )
@@ -130,6 +130,47 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
} }
} }
/** A titled group of settings rendered inside an outlined card. */
@Composable
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp),
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
}
/** A title + subtitle on the left, a Switch on the right. */
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */ /** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable