feat(client/android): profiles — per-host settings, one settings UI
Every client setting was global: pick 4K@120 HEVC and it applies to the beefy desktop, the work laptop and the retro box alike. Android now has the same answer the desktop clients got — named bundles of overrides, bound per host. A profile is a bundle of OVERRIDES, not a snapshot, and getting that right is the whole feature. An untouched field keeps following the global live, so fixing a global once fixes it everywhere. A touched field is recorded even when it equals today's global — that is a pin, and it must survive the global later moving. The only way back to inheriting is an explicit reset, never a diff at save time. `SettingsOverlay.absorb` is the seam that makes this work with a per-control commit: it compares against what the control was SHOWING, not against the globals. One settings UI, not two. A scope chips row on top switches the whole surface between the defaults and one profile — same categories, same rows, same captions — so a profile editor cannot drift from the thing it overrides. In profile scope only profileable rows render (the console-UI toggle, the library, auto-wake, the controller diagnostics are facts about this device and simply aren't there), every row shows the effective value, and an overridden row carries a marker and a reset. On the host side: the Edit sheet binds a default profile and pins profiles as cards; a bound card wears a chip naming what a tap will do; a pinned host+profile combination gets its own card right after its host, one tap instead of a menu — which is also what makes profiles usable on the console, where menus are not. "Connect with" is a one-off from any card and never rebinds; rebinding is always the explicit act of opening the Edit sheet. Precedence is the cross-client one — one-off, else binding, else none — with the empty reference meaning "force the defaults", a real choice on a bound host. A deleted profile leaves a dangling binding that resolves to none and a pin that stops rendering: never an error, never a blocked connect. Resolution happens ONCE per connect, in `ConnectScreen`, and the resolved settings ride the `ActiveSession` into the stream — so the "applies from the next session" footers stay true and the stream can't disagree with the connect that opened it. The stats overlay's first line names the profile, which answers "which profile am I on?" from inside the stream. Nine tests cover the model against the Rust suite's cases (apply, absorb, pins, clear, unknown-key carry-through, id-before-name resolution with ambiguity refused, precedence, deletion) and two Roborazzi scenes cover the surfaces: a profile being edited, and a host grid with a bound chip and a pinned card.
This commit is contained in:
@@ -365,6 +365,7 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
internal fun EditHostDialog(
|
||||
target: KnownHost,
|
||||
suggestedMacs: List<String>,
|
||||
profiles: List<StreamProfile>,
|
||||
onSave: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -375,6 +376,12 @@ internal fun EditHostDialog(
|
||||
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||
}
|
||||
var clipboard by remember(target) { mutableStateOf(target.clipboardSync) }
|
||||
// A binding whose profile was deleted reads as "Default settings" (which is what it already
|
||||
// resolves to) and is cleaned off the record on the next save — never an error state.
|
||||
var boundId by remember(target, profiles) {
|
||||
mutableStateOf(target.profileId?.takeIf { id -> profiles.any { it.id == id } })
|
||||
}
|
||||
var pins by remember(target) { mutableStateOf(target.pinnedProfileIds) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit host") },
|
||||
@@ -424,6 +431,17 @@ internal fun EditHostDialog(
|
||||
}
|
||||
Switch(checked = clipboard, onCheckedChange = { clipboard = it })
|
||||
}
|
||||
if (profiles.isNotEmpty()) {
|
||||
HostProfileBinding(
|
||||
profiles = profiles,
|
||||
boundId = boundId,
|
||||
onBind = { boundId = it },
|
||||
pins = pins,
|
||||
onTogglePin = { id ->
|
||||
pins = if (id in pins) pins - id else pins + id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -437,6 +455,8 @@ internal fun EditHostDialog(
|
||||
port = port.toIntOrNull() ?: target.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
pinnedProfileIds = pins,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -196,6 +197,11 @@ fun ConnectScreen(
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The settings-profile catalog. Read here (not in the settings screen's copy) because this is
|
||||
// where profiles are USED: to resolve what a tap connects with, to offer the one-offs, and to
|
||||
// render the pinned cards. Re-read on entry, since Settings may have changed it in between.
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
var profiles by remember { mutableStateOf(profileStore.all()) }
|
||||
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
|
||||
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
|
||||
// take a minute-plus to advertise again.
|
||||
@@ -268,21 +274,41 @@ fun ConnectScreen(
|
||||
|
||||
// Issue the native connect (shared by the normal connect and the request-access path). A plain
|
||||
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
|
||||
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
|
||||
suspend fun connectNative(
|
||||
id: ClientIdentity,
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
pinHex: String,
|
||||
timeoutMs: Int,
|
||||
profile: StreamProfile?,
|
||||
): Long = connectToHost(
|
||||
context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex,
|
||||
launch = null, timeoutMs = timeoutMs,
|
||||
)
|
||||
|
||||
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
|
||||
// clipboard decision (a property of the record, not a global). A host we never saved — a
|
||||
// connect that failed to pin — falls back to the on default the setting always had.
|
||||
fun session(handle: Long, record: KnownHost?) =
|
||||
ActiveSession(handle, settings, clipboardSync = record?.clipboardSync ?: true)
|
||||
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
|
||||
handle,
|
||||
settings.effectiveFor(profile),
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
)
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||
// fallback) instead of the error status line — discovery is already restarted when it runs, so
|
||||
// the wait can observe the host reappear.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||
fun doConnectDirect(
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
name: String,
|
||||
pinHex: String?,
|
||||
profile: StreamProfile?,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
@@ -293,7 +319,7 @@ fun ConnectScreen(
|
||||
status = null
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile)
|
||||
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
|
||||
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||
if (thisAttempt.cancelled.get()) {
|
||||
@@ -310,7 +336,7 @@ fun ConnectScreen(
|
||||
record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false)
|
||||
}
|
||||
}
|
||||
onConnected(session(handle, record))
|
||||
onConnected(session(handle, record, profile))
|
||||
} else {
|
||||
discovery.start()
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
@@ -347,12 +373,15 @@ fun ConnectScreen(
|
||||
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
|
||||
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||
// seen live) dial straight through.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?, oneOffProfile: String?) {
|
||||
if (identity == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val kh = knownHostStore.get(targetHost, targetPort)
|
||||
// Latched here, not per dial attempt: a wake-and-redial must stream with the same profile
|
||||
// the user asked for, and the "applies from the next session" footers stay truthful.
|
||||
val profile = profileStore.resolveFor(kh, oneOffProfile)
|
||||
val macs = kh?.mac ?: emptyList()
|
||||
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
|
||||
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
|
||||
@@ -363,7 +392,7 @@ fun ConnectScreen(
|
||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, profile, onFailure = {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
@@ -379,12 +408,12 @@ fun ConnectScreen(
|
||||
knownHostStore.save(kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex, profile)
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, profile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +438,9 @@ fun ConnectScreen(
|
||||
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||
val pinHex = target.advertisedFp ?: ""
|
||||
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS)
|
||||
// A host being trusted for the first time can't have a binding yet, so this is always
|
||||
// the plain defaults — a profile only ever enters via a later, deliberate choice.
|
||||
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS, null)
|
||||
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||
// don't touch UI a fresh action may now own.
|
||||
if (req.cancelled.get()) {
|
||||
@@ -427,7 +458,7 @@ fun ConnectScreen(
|
||||
record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
onConnected(session(handle, record))
|
||||
onConnected(session(handle, record, profile = null))
|
||||
} else {
|
||||
// Cause-specific: an operator denial, an approval timeout, and a request that
|
||||
// never reached the host are different problems with different fixes.
|
||||
@@ -450,6 +481,10 @@ fun ConnectScreen(
|
||||
targetPort: Int,
|
||||
dh: DiscoveredHost? = null,
|
||||
manualName: String? = null,
|
||||
// A one-off "Connect with ▸" pick. `null` = follow the host's binding (a plain tap);
|
||||
// `""` = force the global defaults, which is a real choice on a bound host and must
|
||||
// therefore survive as a value rather than collapsing into "unset". NEVER rebinds.
|
||||
oneOffProfile: String? = null,
|
||||
) {
|
||||
// Every dial/pair path funnels through here — with local network access denied the connect
|
||||
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
|
||||
@@ -465,7 +500,7 @@ fun ConnectScreen(
|
||||
when {
|
||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||
known != null && (adv == null || adv == known.fpHex) ->
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex)
|
||||
doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile)
|
||||
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
|
||||
known != null -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
|
||||
@@ -480,6 +515,56 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle a host+profile pin. Presentation only: it never touches the profile itself and never
|
||||
// changes the host's default binding.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
|
||||
}
|
||||
add(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {
|
||||
// The empty reference is "force the defaults", not "unset" — on a bound host that
|
||||
// is a real, different action from a plain tap.
|
||||
connect(kh.address, kh.port, oneOffProfile = "")
|
||||
},
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
|
||||
}
|
||||
if (pin == null) {
|
||||
profiles.forEachIndexed { i, p ->
|
||||
val pinned = p.id in kh.pinnedProfileIds
|
||||
add(
|
||||
HostMenuItem(
|
||||
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
|
||||
startsSection = i == 0,
|
||||
) { togglePin(kh, p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
|
||||
// pinned combination is a plain one-click connect instead of a trip through a menu.
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
if (gamepadUi) {
|
||||
@@ -487,11 +572,15 @@ fun ConnectScreen(
|
||||
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||
val tiles = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.address}:${kh.port}",
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
// The binding is what a press will actually do, so the tile says so — the
|
||||
// console can't edit profiles, but it must never lie about which one it uses.
|
||||
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
|
||||
?: "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
@@ -499,6 +588,22 @@ fun ConnectScreen(
|
||||
activate = { connect(kh.address, kh.port) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press
|
||||
// each, which is the affordance a controller surface does well (menus are not).
|
||||
profileStore.pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
subtitle = p.name,
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
@@ -621,24 +726,42 @@ fun ConnectScreen(
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh ->
|
||||
items(savedCards, key = { it.key }) { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(kh.address, kh.port) },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
// A pinned card connects with ITS profile; the host's own card follows the
|
||||
// binding, which is exactly what its chip says it will do.
|
||||
onConnect = {
|
||||
if (pin != null) {
|
||||
connect(kh.address, kh.port, oneOffProfile = pin.id)
|
||||
} else {
|
||||
connect(kh.address, kh.port)
|
||||
}
|
||||
},
|
||||
onEdit = { editTarget = kh },
|
||||
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
|
||||
// shortcut, not a second host, and offering destructive host actions on it
|
||||
// would blur exactly that.
|
||||
onForget = if (pin != null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
},
|
||||
onEdit = if (pin != null) null else ({ editTarget = kh }),
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
{
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
@@ -657,6 +780,10 @@ fun ConnectScreen(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -753,12 +880,12 @@ fun ConnectScreen(
|
||||
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
doConnect(pt.host, pt.port, pt.name, fp, null)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
@@ -841,6 +968,7 @@ fun ConnectScreen(
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = suggested,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
@@ -881,6 +1009,15 @@ fun ConnectScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
|
||||
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
|
||||
* host entries, which would fork pairing, trust and renames (design §5.2a).
|
||||
*/
|
||||
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The scope switcher: the one new settings concept. Selecting a profile puts the WHOLE settings
|
||||
* surface into that profile's scope — there is one settings UI, never a second parallel editor
|
||||
* that drifts from it. "Default settings" is the base layer every profile inherits from.
|
||||
*
|
||||
* A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and
|
||||
* there are rarely more than a handful. The overflow beside them manages the SELECTED profile
|
||||
* (rename / duplicate / delete); with no profiles at all the row is just "Default settings" and a
|
||||
* "New profile" chip, which is all the clutter a user who never wants this feature ever sees.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProfileScopeChips(
|
||||
profiles: List<StreamProfile>,
|
||||
selectedId: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
onNew: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDuplicate: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedId == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = { Text("Default settings") },
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
FilterChip(
|
||||
selected = selectedId == p.id,
|
||||
onClick = { onSelect(p.id) },
|
||||
label = { Text(p.name) },
|
||||
leadingIcon = p.accent?.let { accent ->
|
||||
{ AccentDot(accentColor(accent) ?: MaterialTheme.colorScheme.primary) }
|
||||
},
|
||||
)
|
||||
}
|
||||
AssistChip(
|
||||
onClick = onNew,
|
||||
label = { Text("New profile") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize))
|
||||
},
|
||||
)
|
||||
if (selectedId != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "Manage profile")
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(text = { Text("Rename…") }, onClick = { menu = false; onRename() })
|
||||
DropdownMenuItem(text = { Text("Duplicate") }, onClick = { menu = false; onDuplicate() })
|
||||
DropdownMenuItem(text = { Text("Delete…") }, onClick = { menu = false; onDelete() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name a profile — used by both "New profile…" and "Rename…". Names must be unique
|
||||
* case-insensitively: two "Work" chips in a menu are ambiguous, and a `punktfunk://…?profile=Work`
|
||||
* link would have to refuse rather than guess. [taken] is the live duplicate check, which lets a
|
||||
* rename keep its own name (and change only its case).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProfileNameDialog(
|
||||
title: String,
|
||||
initialName: String,
|
||||
confirmLabel: String,
|
||||
taken: (String) -> Boolean,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
val trimmed = name.trim()
|
||||
val duplicate = trimmed.isNotEmpty() && taken(trimmed)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text("e.g. Game, Work, Travel") },
|
||||
singleLine = true,
|
||||
isError = duplicate,
|
||||
)
|
||||
Text(
|
||||
if (duplicate) {
|
||||
"A profile called “$trimmed” already exists."
|
||||
} else {
|
||||
"A profile starts out inheriting every default setting. Whatever you " +
|
||||
"change while it's selected becomes an override; everything else " +
|
||||
"keeps following the defaults."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (duplicate) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = trimmed.isNotEmpty() && !duplicate,
|
||||
onClick = { onConfirm(trimmed) },
|
||||
) { Text(confirmLabel) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a profile is not destructive to anything but the profile — a host bound to it falls
|
||||
* back to the default settings and a card pinned to it disappears, neither of which is an error.
|
||||
* The warning counts both so the consequence is stated rather than discovered.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DeleteProfileDialog(
|
||||
profile: StreamProfile,
|
||||
boundHosts: Int,
|
||||
pinnedCards: Int,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete “${profile.name}”?") },
|
||||
text = {
|
||||
Column {
|
||||
val consequences = buildList {
|
||||
if (boundHosts > 0) {
|
||||
add("$boundHosts ${plural(boundHosts, "host", "hosts")} will fall back to the default settings")
|
||||
}
|
||||
if (pinnedCards > 0) {
|
||||
add("$pinnedCards pinned ${plural(pinnedCards, "card", "cards")} will disappear")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (consequences.isEmpty()) {
|
||||
"Nothing uses this profile."
|
||||
} else {
|
||||
consequences.joinToString(", and ") + "."
|
||||
},
|
||||
)
|
||||
Text(
|
||||
"The settings it overrides aren't lost anywhere else — the defaults stay " +
|
||||
"exactly as they are.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun plural(n: Int, one: String, many: String) = if (n == 1) one else many
|
||||
|
||||
/**
|
||||
* The per-host half of profiles, inside the host's Edit sheet: which profile a plain tap uses
|
||||
* (the binding — the one thing that IS sticky; "Connect with ▸" on a card never rebinds), and which
|
||||
* profiles get their own card in the host list.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun HostProfileBinding(
|
||||
profiles: List<StreamProfile>,
|
||||
boundId: String?,
|
||||
onBind: (String?) -> Unit,
|
||||
pins: List<String>,
|
||||
onTogglePin: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val bound = profiles.firstOrNull { it.id == boundId }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = bound?.name ?: "Default settings",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Profile") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Default settings") },
|
||||
onClick = { onBind(null); expanded = false },
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(p.name) },
|
||||
onClick = { onBind(p.id); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"What a tap on this host connects with.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"Pinned cards",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
"A pinned profile gets its own card beside this host — one tap instead of a menu. " +
|
||||
"Pinning changes nothing about which profile is the default.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(p.name, modifier = Modifier.weight(1f))
|
||||
Checkbox(checked = p.id in pins, onCheckedChange = { onTogglePin(p.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The accent marker a profile's chip and its pinned cards wear. */
|
||||
@Composable
|
||||
internal fun AccentDot(color: Color, size: Int = 10) {
|
||||
Box(Modifier.size(size.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
|
||||
/** `#RRGGBB` → a Compose colour, or null when the stored string isn't one (never a crash). */
|
||||
internal fun accentColor(hex: String?): Color? {
|
||||
val h = hex?.removePrefix("#") ?: return null
|
||||
if (h.length != 6 || !h.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return null
|
||||
return runCatching { Color(h.toLong(16) or 0xFF000000L) }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import java.security.SecureRandom
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Client settings profiles — named bundles of setting overrides applied on top of the global
|
||||
* [Settings] (design/client-settings-profiles.md §4). The Kotlin mirror of
|
||||
* `crates/pf-client-core/src/profiles.rs`; the model is the same on every client, so get it right
|
||||
* here rather than re-deciding it.
|
||||
*
|
||||
* A profile overrides only the fields the user touched; everything else keeps following the global
|
||||
* defaults **live**, so fixing a global once fixes it everywhere. That is why an overlay is sparse
|
||||
* nullable fields rather than a snapshot copy, and why a value is written on touch and cleared only
|
||||
* on an explicit "reset to default" — never by diffing against the current global at save time. A
|
||||
* stored value that happens to equal today's global is a legitimate *pin*: the profile keeps it
|
||||
* when the global later moves.
|
||||
*
|
||||
* Only tier-P settings are here. Device facts (which pad this device forwards, whether its console
|
||||
* UI is on) and host facts (clipboard sync, which lives on the host record) are deliberately absent
|
||||
* — see the design's §3 curation.
|
||||
*
|
||||
* Values are stored exactly as [SettingsStore] persists them — ints for the compositor/gamepad wire
|
||||
* bytes, enum names for the rest — so there is one encoding of a setting on this platform rather
|
||||
* than two. The catalog is client-local (v1 has no profile sync or export), so nothing else reads
|
||||
* it.
|
||||
*/
|
||||
data class SettingsOverlay(
|
||||
val width: Int? = null,
|
||||
val height: Int? = null,
|
||||
val hz: Int? = null,
|
||||
val bitrateKbps: Int? = null,
|
||||
val renderScale: Double? = null,
|
||||
val codec: String? = null,
|
||||
val hdrEnabled: Boolean? = null,
|
||||
val compositor: Int? = null,
|
||||
val audioChannels: Int? = null,
|
||||
val micEnabled: Boolean? = null,
|
||||
val touchMode: TouchMode? = null,
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
* else, but here it is the one knob a marginal link wants turned off per host.
|
||||
*/
|
||||
val lowLatencyMode: Boolean? = null,
|
||||
/**
|
||||
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
|
||||
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
|
||||
* must not erase what a newer one stored.
|
||||
*/
|
||||
val extra: Map<String, Any> = emptyMap(),
|
||||
) {
|
||||
/** The one resolution seam: this overlay on top of [base]. Pure, so it is fully testable. */
|
||||
fun apply(base: Settings): Settings = base.copy(
|
||||
width = width ?: base.width,
|
||||
height = height ?: base.height,
|
||||
hz = hz ?: base.hz,
|
||||
bitrateKbps = bitrateKbps ?: base.bitrateKbps,
|
||||
renderScale = renderScale ?: base.renderScale,
|
||||
codec = codec ?: base.codec,
|
||||
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
|
||||
compositor = compositor ?: base.compositor,
|
||||
audioChannels = audioChannels ?: base.audioChannels,
|
||||
micEnabled = micEnabled ?: base.micEnabled,
|
||||
touchMode = touchMode ?: base.touchMode,
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
)
|
||||
|
||||
/**
|
||||
* Record, as overrides, every tier-P field that differs between two settings snapshots.
|
||||
*
|
||||
* The settings UI commits a whole `Settings` per control (`update(s.copy(codec = …))`), so it
|
||||
* can't hand over a list of touched fields — it hands over "what the control was showing" and
|
||||
* "what it shows now", and the only field that can differ is the one the user just touched.
|
||||
*
|
||||
* This is NOT the diff-on-save the design rejects: the comparison is against the EFFECTIVE
|
||||
* settings the control was displaying, not against the globals, so setting a value back to
|
||||
* whatever the global happens to be still records an override — the pin. It only ever adds
|
||||
* overrides; removing one is [clear], a different, explicit operation.
|
||||
*/
|
||||
fun absorb(before: Settings, after: Settings): SettingsOverlay = copy(
|
||||
width = if (after.width != before.width) after.width else width,
|
||||
height = if (after.height != before.height) after.height else height,
|
||||
hz = if (after.hz != before.hz) after.hz else hz,
|
||||
bitrateKbps = if (after.bitrateKbps != before.bitrateKbps) after.bitrateKbps else bitrateKbps,
|
||||
renderScale = if (after.renderScale != before.renderScale) after.renderScale else renderScale,
|
||||
codec = if (after.codec != before.codec) after.codec else codec,
|
||||
hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled,
|
||||
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
|
||||
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
|
||||
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
|
||||
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drop one override by its field name, putting the row back to inheriting. [FIELD_RESOLUTION]
|
||||
* is the one alias, covering the width/height pair a single control drives. An unknown name is
|
||||
* a no-op.
|
||||
*/
|
||||
fun clear(field: String): SettingsOverlay = when (field) {
|
||||
FIELD_RESOLUTION -> copy(width = null, height = null)
|
||||
"refresh_hz" -> copy(hz = null)
|
||||
"bitrate_kbps" -> copy(bitrateKbps = null)
|
||||
"render_scale" -> copy(renderScale = null)
|
||||
"codec" -> copy(codec = null)
|
||||
"hdr_enabled" -> copy(hdrEnabled = null)
|
||||
"compositor" -> copy(compositor = null)
|
||||
"audio_channels" -> copy(audioChannels = null)
|
||||
"mic_enabled" -> copy(micEnabled = null)
|
||||
"touch_mode" -> copy(touchMode = null)
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
else -> this
|
||||
}
|
||||
|
||||
/** The field names this overlay overrides — what the settings rows draw their markers from. */
|
||||
fun overridden(): Set<String> = buildSet {
|
||||
if (width != null || height != null) add(FIELD_RESOLUTION)
|
||||
if (hz != null) add("refresh_hz")
|
||||
if (bitrateKbps != null) add("bitrate_kbps")
|
||||
if (renderScale != null) add("render_scale")
|
||||
if (codec != null) add("codec")
|
||||
if (hdrEnabled != null) add("hdr_enabled")
|
||||
if (compositor != null) add("compositor")
|
||||
if (audioChannels != null) add("audio_channels")
|
||||
if (micEnabled != null) add("mic_enabled")
|
||||
if (touchMode != null) add("touch_mode")
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the profile overrides nothing — "inherits everything", the state a freshly created
|
||||
* profile starts in. A profile holding only a newer build's field is NOT empty.
|
||||
*/
|
||||
fun isEmpty(): Boolean = overridden().isEmpty() && extra.isEmpty()
|
||||
|
||||
internal fun toJson(): JSONObject {
|
||||
val j = JSONObject()
|
||||
// Unknown keys first, so a modelled field always wins over a stale carried-through one.
|
||||
extra.forEach { (k, v) -> j.put(k, v) }
|
||||
width?.let { j.put("width", it) }
|
||||
height?.let { j.put("height", it) }
|
||||
hz?.let { j.put("refresh_hz", it) }
|
||||
bitrateKbps?.let { j.put("bitrate_kbps", it) }
|
||||
renderScale?.let { j.put("render_scale", it) }
|
||||
codec?.let { j.put("codec", it) }
|
||||
hdrEnabled?.let { j.put("hdr_enabled", it) }
|
||||
compositor?.let { j.put("compositor", it) }
|
||||
audioChannels?.let { j.put("audio_channels", it) }
|
||||
micEnabled?.let { j.put("mic_enabled", it) }
|
||||
touchMode?.let { j.put("touch_mode", it.name) }
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
return j
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The width/height pair, which one control drives — the reset alias, as on every client. */
|
||||
const val FIELD_RESOLUTION = "resolution"
|
||||
|
||||
/** Keys this build models; everything else in a stored overlay is carried through. */
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
|
||||
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
|
||||
)
|
||||
|
||||
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
||||
width = j.optIntOrNull("width"),
|
||||
height = j.optIntOrNull("height"),
|
||||
hz = j.optIntOrNull("refresh_hz"),
|
||||
bitrateKbps = j.optIntOrNull("bitrate_kbps"),
|
||||
renderScale = if (j.has("render_scale")) j.optDouble("render_scale") else null,
|
||||
codec = j.optStringOrNull("codec"),
|
||||
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
|
||||
compositor = j.optIntOrNull("compositor"),
|
||||
audioChannels = j.optIntOrNull("audio_channels"),
|
||||
micEnabled = j.optBooleanOrNull("mic_enabled"),
|
||||
touchMode = j.optStringOrNull("touch_mode")
|
||||
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
|
||||
mouseMode = j.optStringOrNull("mouse_mode")
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One named bundle of overrides. [id] is stable across renames — host bindings, pinned cards and
|
||||
* `punktfunk://` links all point at it, never at the name.
|
||||
*/
|
||||
data class StreamProfile(
|
||||
val id: String,
|
||||
/** User-facing and editable; unique case-insensitively (menus are ambiguous otherwise). */
|
||||
val name: String,
|
||||
/** `#RRGGBB` chip colour. Reserved by the schema; pinned cards tint their subtitle with it. */
|
||||
val accent: String? = null,
|
||||
val overrides: SettingsOverlay = SettingsOverlay(),
|
||||
/** Profile keys a newer build wrote — preserved across a load→save round-trip. */
|
||||
val extra: Map<String, Any> = emptyMap(),
|
||||
)
|
||||
|
||||
/** What a `profile=` / one-off reference resolved to. Ambiguity is reported, never guessed. */
|
||||
enum class ProfileResolution { FOUND, NOT_FOUND, AMBIGUOUS }
|
||||
|
||||
/**
|
||||
* The profile catalog — client-wide, not per host: "Work" applied to three hosts is one profile,
|
||||
* and the per-host part is only the binding on the host record ([KnownHost.profileId]).
|
||||
*
|
||||
* Stored one JSON string per profile keyed by id in its own `punktfunk_profiles` prefs file — the
|
||||
* `KnownHostStore` pattern, and deliberately not inside the settings file, which is rewritten
|
||||
* wholesale by several writers.
|
||||
*/
|
||||
class ProfileStore(context: Context) {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences("punktfunk_profiles", Context.MODE_PRIVATE)
|
||||
|
||||
/** Every profile, name-sorted — the order the scope switcher and the menus show. */
|
||||
fun all(): List<StreamProfile> = prefs.all.values
|
||||
.mapNotNull { (it as? String)?.let(::parse) }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
|
||||
fun byId(id: String): StreamProfile? = prefs.getString(id, null)?.let(::parse)
|
||||
|
||||
fun save(profile: StreamProfile) {
|
||||
prefs.edit().putString(profile.id, encode(profile)).apply()
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
prefs.edit().remove(id).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a reference the way every surface must: exact id first, then a unique
|
||||
* case-insensitive name. Two profiles sharing a name resolve to [ProfileResolution.AMBIGUOUS]
|
||||
* — a link or a flag naming two profiles must refuse, not pick whichever came first.
|
||||
*/
|
||||
fun resolve(reference: String): Pair<StreamProfile?, ProfileResolution> {
|
||||
if (reference.isEmpty()) return null to ProfileResolution.NOT_FOUND
|
||||
byId(reference)?.let { return it to ProfileResolution.FOUND }
|
||||
val hits = all().filter { it.name.equals(reference, ignoreCase = true) }
|
||||
return when (hits.size) {
|
||||
1 -> hits[0] to ProfileResolution.FOUND
|
||||
0 -> null to ProfileResolution.NOT_FOUND
|
||||
else -> null to ProfileResolution.AMBIGUOUS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this name already used (case-insensitively) by a *different* profile? The create/rename
|
||||
* guard — [except] is the profile being renamed, so renaming "Work" to "work" is allowed.
|
||||
*/
|
||||
fun nameTaken(name: String, except: String? = null): Boolean =
|
||||
all().any { it.name.equals(name, ignoreCase = true) && it.id != except }
|
||||
|
||||
/**
|
||||
* The profile a connect to [host] should use: the one-off pick, else the host's binding, else
|
||||
* none. [oneOff] is a reference (id or unique name); the empty string means "force the global
|
||||
* defaults" — a real choice ("Connect with ▸ Default settings" on a bound host), not "unset",
|
||||
* which is why it must survive as a value all the way down here. A binding whose profile was
|
||||
* deleted resolves as none: never an error, never a blocked connect.
|
||||
*/
|
||||
fun resolveFor(host: KnownHost?, oneOff: String?): StreamProfile? = when {
|
||||
oneOff != null -> resolve(oneOff).first
|
||||
else -> host?.profileId?.let(::byId)
|
||||
}
|
||||
|
||||
/** [host]'s pinned profiles, in card order, with duplicates and deleted profiles dropped. */
|
||||
fun pinsFor(host: KnownHost): List<StreamProfile> =
|
||||
host.pinnedProfileIds.distinct().mapNotNull(::byId)
|
||||
|
||||
private fun parse(s: String): StreamProfile? = runCatching {
|
||||
val j = JSONObject(s)
|
||||
StreamProfile(
|
||||
id = j.getString("id"),
|
||||
name = j.getString("name"),
|
||||
accent = j.optStringOrNull("accent"),
|
||||
overrides = SettingsOverlay.fromJson(j.optJSONObject("overrides") ?: JSONObject()),
|
||||
extra = j.keys().asSequence()
|
||||
.filter { it !in setOf("id", "name", "accent", "overrides") }
|
||||
.associateWith { j.get(it) },
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun encode(p: StreamProfile): String {
|
||||
val j = JSONObject()
|
||||
p.extra.forEach { (k, v) -> j.put(k, v) }
|
||||
j.put("id", p.id)
|
||||
j.put("name", p.name)
|
||||
p.accent?.let { j.put("accent", it) }
|
||||
j.put("overrides", p.overrides.toJson())
|
||||
return j.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A new, empty profile: it inherits everything, which is the right creation default under
|
||||
* inherit-by-exception (Duplicate covers "start from that other profile"). The id is 12 lowercase
|
||||
* hex characters — the shape the Rust `new_profile_id` mints.
|
||||
*/
|
||||
fun newProfile(name: String): StreamProfile = StreamProfile(id = newProfileId(), name = name)
|
||||
|
||||
private val PROFILE_ID_RNG = SecureRandom()
|
||||
|
||||
fun newProfileId(): String {
|
||||
val b = ByteArray(6)
|
||||
PROFILE_ID_RNG.nextBytes(b)
|
||||
return b.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings a connect to [host] should use: the resolved profile's overrides on top of these
|
||||
* globals, resolved ONCE per connect (matching the latch-at-connect model the "applies from the
|
||||
* next session" footers promise). See [ProfileStore.resolveFor] for the precedence.
|
||||
*/
|
||||
fun Settings.effectiveFor(profile: StreamProfile?): Settings =
|
||||
profile?.overrides?.apply(this) ?: this
|
||||
|
||||
// ---- org.json null-vs-absent helpers (optInt and friends can't tell 0 from "not there") ---------
|
||||
|
||||
private fun JSONObject.optIntOrNull(key: String): Int? = if (has(key)) optInt(key) else null
|
||||
private fun JSONObject.optBooleanOrNull(key: String): Boolean? =
|
||||
if (has(key)) optBoolean(key) else null
|
||||
|
||||
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||
if (has(key)) optString(key).ifEmpty { null } else null
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -51,9 +52,12 @@ import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -72,12 +76,13 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
/**
|
||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
|
||||
* persist immediately via [onChange]; [onBack] returns to the connect screen.
|
||||
* persist immediately; [onBack] returns to the connect screen.
|
||||
*
|
||||
* **Structure mirrors the desktop/Apple settings revamp** ([SettingsCategory], and the Windows
|
||||
* client's `app/settings.rs`), so every client reads the same way: General = session/app behaviour,
|
||||
@@ -86,6 +91,12 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
* [SettingDropdown]'s `caption` and [ToggleRow]'s `subtitle`) rather than as loose paragraphs
|
||||
* floating between controls; the only form-level notes are the "applies from the next session"
|
||||
* footers, one per affected category.
|
||||
*
|
||||
* **One settings UI, not two.** The scope switcher on top edits either the global defaults
|
||||
* ([onChange], the base layer every profile inherits from) or one profile's overrides — the same
|
||||
* rows either way, so a profile can never drift from the surface it overrides. In profile scope
|
||||
* only profileable settings render, every row shows the EFFECTIVE value, and a row the profile
|
||||
* overrides carries a marker and a reset. See [SettingsOverlay] for the model.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -98,14 +109,48 @@ fun SettingsScreen(
|
||||
* `PUNKTFUNK_SHOT_SETTINGS_SCOPE` seeds its scope.
|
||||
*/
|
||||
initialCategory: SettingsCategory? = null,
|
||||
/** Seeds the scope the same way, for a screenshot of a profile being edited. */
|
||||
initialProfileId: String? = null,
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
var globals by remember { mutableStateOf(initial) }
|
||||
val context = LocalContext.current
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val hostStore = remember { KnownHostStore(context) }
|
||||
var profiles by remember { mutableStateOf(profileStore.all()) }
|
||||
// Which layer is being edited: null = the global defaults, else a profile id. Survives rotation
|
||||
// but not a trip out of Settings — coming back should land on the defaults, which is what the
|
||||
// host cards actually connect with unless they say otherwise.
|
||||
var scopeId by rememberSaveable { mutableStateOf(initialProfileId) }
|
||||
// A profile deleted from under the scope (or a store that never had it) falls back to defaults.
|
||||
val active = scopeId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
if (scopeId != null && active == null) scopeId = null
|
||||
|
||||
var showLicenses by remember { mutableStateOf(false) }
|
||||
var showControllers by remember { mutableStateOf(false) }
|
||||
var naming by remember { mutableStateOf<NameIntent?>(null) }
|
||||
var deleting by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Every row renders the EFFECTIVE value: the globals with this profile's overrides on top, so a
|
||||
// row the profile doesn't override reads as the live global — and keeps following it.
|
||||
val s = globals.effectiveFor(active)
|
||||
|
||||
fun update(next: Settings) {
|
||||
s = next
|
||||
onChange(next)
|
||||
val profile = active
|
||||
if (profile == null) {
|
||||
globals = next
|
||||
onChange(next)
|
||||
} else {
|
||||
// `absorb` against what the control was SHOWING, so picking today's global value is
|
||||
// still recorded as an override (the pin) — see [SettingsOverlay.absorb].
|
||||
profileStore.save(profile.copy(overrides = profile.overrides.absorb(s, next)))
|
||||
profiles = profileStore.all()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetField(field: String) {
|
||||
val profile = active ?: return
|
||||
profileStore.save(profile.copy(overrides = profile.overrides.clear(field)))
|
||||
profiles = profileStore.all()
|
||||
}
|
||||
|
||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||
@@ -133,96 +178,255 @@ fun SettingsScreen(
|
||||
|
||||
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
|
||||
var selectedName by rememberSaveable { mutableStateOf(initialCategory?.name) }
|
||||
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
|
||||
val categories = SettingsCategory.entries.filter { active == null || it.profileable }
|
||||
val selected = selectedName
|
||||
?.let { n -> categories.firstOrNull { it.name == n } }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val twoPane = maxWidth >= 640.dp
|
||||
// A two-column layout must never show an empty detail — land on the first category.
|
||||
LaunchedEffect(twoPane) {
|
||||
if (twoPane && selected == null) selectedName = SettingsCategory.General.name
|
||||
}
|
||||
|
||||
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||
CategoryDetail(
|
||||
category = cat,
|
||||
settings = s,
|
||||
onChange = ::update,
|
||||
context = context,
|
||||
onMicChange = onMicChange,
|
||||
onOpenControllers = { showControllers = true },
|
||||
onOpenLicenses = { showLicenses = true },
|
||||
onBack = back,
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ProfileScopeChips(
|
||||
profiles = profiles,
|
||||
selectedId = active?.id,
|
||||
onSelect = { id ->
|
||||
scopeId = id
|
||||
// About has no profileable rows at all; don't strand the pane on it.
|
||||
if (id != null && selected?.profileable == false) selectedName = null
|
||||
},
|
||||
onNew = { naming = NameIntent.New },
|
||||
onRename = { active?.let { naming = NameIntent.Rename(it) } },
|
||||
onDuplicate = {
|
||||
active?.let { p ->
|
||||
val copy = newProfile(uniqueName(profileStore, p.name)).copy(
|
||||
accent = p.accent,
|
||||
overrides = p.overrides,
|
||||
)
|
||||
profileStore.save(copy)
|
||||
profiles = profileStore.all()
|
||||
scopeId = copy.id
|
||||
}
|
||||
},
|
||||
onDelete = { deleting = active },
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
if (active != null) {
|
||||
Text(
|
||||
"Overrides the default settings for hosts that use “${active.name}”.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (twoPane) {
|
||||
BackHandler(onBack = onBack)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
CategoryList(
|
||||
selected = selected,
|
||||
twoPane = true,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||
)
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Cross-fade the detail pane as the selected category changes.
|
||||
AnimatedContent(
|
||||
targetState = selected ?: SettingsCategory.General,
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "SettingsPane",
|
||||
) { cat -> detail(cat, null) }
|
||||
CompositionLocalProvider(
|
||||
LocalSettingsScope provides SettingsScopeState(
|
||||
profileScope = active != null,
|
||||
overridden = active?.overrides?.overridden() ?: emptySet(),
|
||||
onReset = ::resetField,
|
||||
),
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val twoPane = maxWidth >= 640.dp
|
||||
// A two-column layout must never show an empty detail — land on the first category.
|
||||
LaunchedEffect(twoPane, categories) {
|
||||
if (twoPane && selected == null) selectedName = categories.first().name
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
|
||||
// Android system settings — a horizontal slide that tracks the drill-in direction.
|
||||
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
transitionSpec = {
|
||||
if (targetState != null) {
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { it } + fadeOut()
|
||||
}
|
||||
},
|
||||
label = "SettingsPush",
|
||||
) { sel ->
|
||||
if (sel == null) {
|
||||
CategoryList(
|
||||
selected = null,
|
||||
twoPane = false,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||
CategoryDetail(
|
||||
category = cat,
|
||||
settings = s,
|
||||
onChange = ::update,
|
||||
context = context,
|
||||
onMicChange = onMicChange,
|
||||
onOpenControllers = { showControllers = true },
|
||||
onOpenLicenses = { showLicenses = true },
|
||||
onBack = back,
|
||||
)
|
||||
}
|
||||
|
||||
if (twoPane) {
|
||||
BackHandler(onBack = onBack)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
CategoryList(
|
||||
categories = categories,
|
||||
selected = selected,
|
||||
twoPane = true,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||
)
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Cross-fade the detail pane as the selected category changes.
|
||||
AnimatedContent(
|
||||
targetState = selected ?: categories.first(),
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "SettingsPane",
|
||||
) { cat -> detail(cat, null) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
detail(sel) { selectedName = null }
|
||||
// Compact: the category list pushes to a full-screen detail and back, like the
|
||||
// iOS / Android system settings — a horizontal slide tracking the drill-in.
|
||||
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
transitionSpec = {
|
||||
if (targetState != null) {
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { it } + fadeOut()
|
||||
}
|
||||
},
|
||||
label = "SettingsPush",
|
||||
) { sel ->
|
||||
if (sel == null) {
|
||||
CategoryList(
|
||||
categories = categories,
|
||||
selected = null,
|
||||
twoPane = false,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
detail(sel) { selectedName = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
naming?.let { intent ->
|
||||
val editing = (intent as? NameIntent.Rename)?.profile
|
||||
ProfileNameDialog(
|
||||
title = if (editing == null) "New profile" else "Rename profile",
|
||||
initialName = editing?.name.orEmpty(),
|
||||
confirmLabel = if (editing == null) "Create" else "Rename",
|
||||
taken = { profileStore.nameTaken(it, except = editing?.id) },
|
||||
onConfirm = { name ->
|
||||
val saved = editing?.copy(name = name) ?: newProfile(name)
|
||||
profileStore.save(saved)
|
||||
profiles = profileStore.all()
|
||||
scopeId = saved.id
|
||||
naming = null
|
||||
},
|
||||
onDismiss = { naming = null },
|
||||
)
|
||||
}
|
||||
|
||||
deleting?.let { profile ->
|
||||
val hosts = hostStore.all()
|
||||
DeleteProfileDialog(
|
||||
profile = profile,
|
||||
boundHosts = hosts.count { it.profileId == profile.id },
|
||||
pinnedCards = hosts.count { profile.id in it.pinnedProfileIds },
|
||||
onConfirm = {
|
||||
profileStore.delete(profile.id)
|
||||
profiles = profileStore.all()
|
||||
scopeId = null
|
||||
deleting = null
|
||||
// Bindings and pins are left dangling on purpose: they resolve to "no profile" and
|
||||
// to "no card", which is exactly right, and rewriting every host record here would
|
||||
// be a second write pass over data the user didn't ask us to touch.
|
||||
},
|
||||
onDismiss = { deleting = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What the name dialog is for. */
|
||||
private sealed interface NameIntent {
|
||||
data object New : NameIntent
|
||||
data class Rename(val profile: StreamProfile) : NameIntent
|
||||
}
|
||||
|
||||
/** "Work" → "Work copy" → "Work copy 2" — the first name Duplicate can actually save. */
|
||||
private fun uniqueName(store: ProfileStore, base: String): String {
|
||||
val first = "$base copy"
|
||||
if (!store.nameTaken(first)) return first
|
||||
var n = 2
|
||||
while (store.nameTaken("$first $n")) n++
|
||||
return "$first $n"
|
||||
}
|
||||
|
||||
// ---- Scope plumbing ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What the rows need to know about the layer being edited. Carried as a composition local rather
|
||||
* than threaded through every category and row: the rows are the same rows in both scopes, and the
|
||||
* scope only decides whether tier-G rows render at all and whether a row wears an override marker.
|
||||
*/
|
||||
private class SettingsScopeState(
|
||||
val profileScope: Boolean,
|
||||
val overridden: Set<String>,
|
||||
val onReset: (String) -> Unit,
|
||||
)
|
||||
|
||||
private val LocalSettingsScope = compositionLocalOf {
|
||||
SettingsScopeState(profileScope = false, overridden = emptySet(), onReset = {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps rows that are facts about THIS DEVICE or this app rather than about a stream — the console
|
||||
* UI toggle, the library browser, auto-wake, the controller diagnostics, rumble mirroring, SC2
|
||||
* capture (design §3, tiers G and H). They never belong to a profile, so in profile scope they
|
||||
* simply don't render.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceScopeOnly(content: @Composable () -> Unit) {
|
||||
if (!LocalSettingsScope.current.profileScope) content()
|
||||
}
|
||||
|
||||
/**
|
||||
* The accent marker and reset a row wears when the selected profile overrides it. Nothing renders
|
||||
* in the defaults scope, or on a row the profile inherits — an inherited row shows the live global
|
||||
* value in the ordinary quiet style, which is the whole point of inherit-by-default.
|
||||
*/
|
||||
@Composable
|
||||
private fun OverrideBadge(field: String?) {
|
||||
val scope = LocalSettingsScope.current
|
||||
if (!scope.profileScope || field == null || field !in scope.overridden) return
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
AccentDot(MaterialTheme.colorScheme.primary, size = 8)
|
||||
Text(
|
||||
"Overridden",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 6.dp).weight(1f),
|
||||
)
|
||||
TextButton(onClick = { scope.onReset(field) }) { Text("Reset to default") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Categories --------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The top-level settings groups — each opens its own subpage (list on phone, split on tablet).
|
||||
* The map and its order are the cross-client one (Apple's `SettingsCategory`, the Windows
|
||||
* NavigationView, the GTK pages): General, Display, Input, Audio, Controllers, About.
|
||||
*
|
||||
* [profileable] is false for a category with no profileable rows at all — it isn't offered in
|
||||
* profile scope, rather than opening onto an empty page.
|
||||
*/
|
||||
enum class SettingsCategory(val title: String, val icon: ImageVector) {
|
||||
enum class SettingsCategory(
|
||||
val title: String,
|
||||
val icon: ImageVector,
|
||||
internal val profileable: Boolean = true,
|
||||
) {
|
||||
General("General", Icons.Filled.Tune),
|
||||
Display("Display", Icons.Filled.Tv),
|
||||
Input("Input", Icons.Filled.TouchApp),
|
||||
Audio("Audio", Icons.AutoMirrored.Filled.VolumeUp),
|
||||
Controllers("Controllers", Icons.Filled.SportsEsports),
|
||||
About("About", Icons.Filled.Info),
|
||||
About("About", Icons.Filled.Info, profileable = false),
|
||||
}
|
||||
|
||||
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
|
||||
@Composable
|
||||
private fun CategoryList(
|
||||
categories: List<SettingsCategory>,
|
||||
selected: SettingsCategory?,
|
||||
twoPane: Boolean,
|
||||
onSelect: (SettingsCategory) -> Unit,
|
||||
@@ -239,7 +443,7 @@ private fun CategoryList(
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||
)
|
||||
SettingsCategory.entries.forEach { cat ->
|
||||
categories.forEach { cat ->
|
||||
val highlighted = twoPane && selected == cat
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -307,48 +511,51 @@ private fun CategoryDetail(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The categories ----------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsGroup("Session") {
|
||||
ToggleRow(
|
||||
title = "Auto-wake on connect",
|
||||
subtitle = "Connecting to a saved host that isn't seen on the network sends " +
|
||||
"Wake-on-LAN and waits for it to boot. Turn off if hosts behind a VPN look " +
|
||||
"offline when they aren't — connects then go straight through.",
|
||||
checked = s.autoWakeEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||
)
|
||||
DeviceScopeOnly {
|
||||
SettingsGroup("Session") {
|
||||
ToggleRow(
|
||||
title = "Auto-wake on connect",
|
||||
subtitle = "Connecting to a saved host that isn't seen on the network sends " +
|
||||
"Wake-on-LAN and waits for it to boot. Turn off if hosts behind a VPN look " +
|
||||
"offline when they aren't — connects then go straight through.",
|
||||
checked = s.autoWakeEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup("Statistics") {
|
||||
SettingDropdown(
|
||||
label = "Stats overlay",
|
||||
options = STATS_VERBOSITY_OPTIONS,
|
||||
selected = s.statsVerbosity,
|
||||
field = "stats_verbosity",
|
||||
caption = "Live session stats in a corner overlay — Compact is a single " +
|
||||
"fps · latency · bitrate line, Normal adds the resolution and reliability lines, " +
|
||||
"Detailed adds the decoder, colour and latency-breakdown lines. A 3-finger tap " +
|
||||
"cycles the tiers live.",
|
||||
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||
}
|
||||
SettingsGroup("Library") {
|
||||
ToggleRow(
|
||||
title = "Game library",
|
||||
subtitle = "Browse a paired host's Steam and custom games and launch one directly " +
|
||||
"(press Y on a saved host). No extra host setup.",
|
||||
checked = s.libraryEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||
)
|
||||
}
|
||||
SettingsGroup("Interface") {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home (host carousel) whenever a controller is " +
|
||||
"connected. Turn off to keep the touch interface. A TV is always in this mode.",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
DeviceScopeOnly {
|
||||
SettingsGroup("Library") {
|
||||
ToggleRow(
|
||||
title = "Game library",
|
||||
subtitle = "Browse a paired host's Steam and custom games and launch one directly " +
|
||||
"(press Y on a saved host). No extra host setup.",
|
||||
checked = s.libraryEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||
)
|
||||
}
|
||||
SettingsGroup("Interface") {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home (host carousel) whenever a controller is " +
|
||||
"connected. Turn off to keep the touch interface. A TV is always in this mode.",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +575,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
// stored its label carries the live value, like the native row carries ($nw × $nh).
|
||||
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
|
||||
selected = if (showCustom) -1 to -1 else s.width to s.height,
|
||||
field = SettingsOverlay.FIELD_RESOLUTION,
|
||||
caption = "The host drives a real virtual output at exactly this size — true pixels, " +
|
||||
"no scaling. “Native display” follows this device's panel.",
|
||||
) { (w, h) ->
|
||||
@@ -396,6 +604,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
label = "Refresh rate",
|
||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||
selected = s.hz,
|
||||
field = "refresh_hz",
|
||||
caption = "“Native” resolves to this display's refresh rate at connect.",
|
||||
) { hz -> update(s.copy(hz = hz)) }
|
||||
}
|
||||
@@ -407,6 +616,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
|
||||
// exact Double keys match.
|
||||
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
|
||||
field = "render_scale",
|
||||
caption = "Above native supersamples for sharpness, at more bandwidth AND decode; " +
|
||||
"below renders lighter on the host and the link. This device resamples the " +
|
||||
"result to the screen.",
|
||||
@@ -416,6 +626,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
label = "Bitrate",
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
field = "bitrate_kbps",
|
||||
caption = "Automatic lets the host decide (its default, clamped to what it supports).",
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
|
||||
@@ -426,6 +637,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
label = "Video codec",
|
||||
options = codecOptionsFor(s.codec, av1Capable),
|
||||
selected = s.codec,
|
||||
field = "codec",
|
||||
caption = "A preference — the host falls back if it can't encode this one.",
|
||||
) { c -> update(s.copy(codec = c)) }
|
||||
|
||||
@@ -442,13 +654,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
},
|
||||
checked = s.hdrEnabled && hdrCapable,
|
||||
enabled = hdrCapable,
|
||||
field = "hdr_enabled",
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
// The decode pipeline is a fact about THIS device's SoC, not about the stream — the desktop
|
||||
// clients group their decoder/GPU pickers here for the same reason. Android has no decoder or
|
||||
// adapter choice (MediaCodec resolves both), so the master toggle is this group's only row.
|
||||
// The desktop clients group their decoder and GPU pickers here, and keep them out of profiles —
|
||||
// they are facts about that machine's hardware. Android has neither choice (MediaCodec resolves
|
||||
// both), and the one knob it does have IS worth varying per host: a marginal link is exactly
|
||||
// where you want the plain decode path back (design §3 lists it as an Android-only profileable).
|
||||
SettingsGroup("Decoding") {
|
||||
ToggleRow(
|
||||
title = "Low-latency mode",
|
||||
@@ -456,6 +670,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
"mode). On by default — turn off to fall back to the plain decode path if the " +
|
||||
"stream stutters or glitches on this device.",
|
||||
checked = s.lowLatencyMode,
|
||||
field = "low_latency_mode",
|
||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||
)
|
||||
}
|
||||
@@ -465,6 +680,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
field = "compositor",
|
||||
caption = "The backend the host uses for its virtual output (Linux hosts only). A " +
|
||||
"specific choice falls back to auto-detection when that backend isn't available.",
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
@@ -478,6 +694,7 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
label = "Touch input",
|
||||
options = TOUCH_MODE_OPTIONS,
|
||||
selected = s.touchMode,
|
||||
field = "touch_mode",
|
||||
caption = "Trackpad: relative cursor like a laptop touchpad — tap to click, " +
|
||||
"two-finger tap right-clicks, two fingers scroll, tap-then-drag holds the " +
|
||||
"button. Direct pointer: the cursor jumps to your finger. Touch passthrough: " +
|
||||
@@ -489,6 +706,7 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
label = "Mouse input",
|
||||
options = MOUSE_MODE_OPTIONS,
|
||||
selected = s.mouseMode,
|
||||
field = "mouse_mode",
|
||||
caption = "Capture locks a connected mouse to the stream and sends relative motion " +
|
||||
"(mouse-look) — best for games; click the stream to re-capture. Desktop leaves " +
|
||||
"the pointer free to enter and leave the stream and sends absolute positions — " +
|
||||
@@ -498,10 +716,11 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
title = "Invert scroll direction",
|
||||
subtitle = "Reverses the wheel and two-finger touch scroll direction sent to the host",
|
||||
checked = s.invertScroll,
|
||||
field = "invert_scroll",
|
||||
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
|
||||
)
|
||||
// "Shared clipboard" is NOT here any more: it is a trust decision about one host, so it
|
||||
// lives on the host record and is edited from that host's Edit sheet.
|
||||
// "Shared clipboard" is NOT here: it is a trust decision about one host, so it lives on the
|
||||
// host record and is edited from that host's Edit sheet.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +731,7 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
label = "Audio channels",
|
||||
options = AUDIO_CHANNEL_OPTIONS,
|
||||
selected = s.audioChannels,
|
||||
field = "audio_channels",
|
||||
caption = "The speaker layout requested from the host. It downmixes if its own " +
|
||||
"output has fewer channels.",
|
||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||
@@ -519,6 +739,7 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
title = "Microphone",
|
||||
subtitle = "This device's microphone feeds the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
field = "mic_enabled",
|
||||
onCheckedChange = onMicChange,
|
||||
)
|
||||
}
|
||||
@@ -531,39 +752,42 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS,
|
||||
selected = s.gamepad,
|
||||
field = "gamepad",
|
||||
caption = "The virtual pad created on the host. Automatic matches your controller — " +
|
||||
"a DualSense keeps adaptive triggers, lightbar, touchpad and motion. Every " +
|
||||
"connected controller is forwarded, each as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
// Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row
|
||||
// would be a silent no-op there.
|
||||
val context = LocalContext.current
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
if (hasBodyVibrator) {
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
// Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row
|
||||
// would be a silent no-op there.
|
||||
val context = LocalContext.current
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
if (hasBodyVibrator) {
|
||||
ToggleRow(
|
||||
title = "Rumble on this phone",
|
||||
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
||||
"motor — for clip-on pads without rumble motors",
|
||||
checked = s.rumbleOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
ToggleRow(
|
||||
title = "Rumble on this phone",
|
||||
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
||||
"motor — for clip-on pads without rumble motors",
|
||||
checked = s.rumbleOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
title = "Steam Controller 2 passthrough",
|
||||
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
|
||||
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
|
||||
"host drives it like the physical pad (trackpads, gyro, haptics)",
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
ToggleRow(
|
||||
title = "Steam Controller 2 passthrough",
|
||||
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
|
||||
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
|
||||
"host drives it like the physical pad (trackpads, gyro, haptics)",
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +863,10 @@ private fun SettingsGroup(
|
||||
}
|
||||
}
|
||||
|
||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||
/**
|
||||
* A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row;
|
||||
* [field] is the overlay field this row writes, which is what its override marker keys on.
|
||||
*/
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
title: String,
|
||||
@@ -647,23 +874,27 @@ private fun ToggleRow(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
field: String? = null,
|
||||
) {
|
||||
// Dim the labels when disabled so the row reads as inactive (the Switch dims itself).
|
||||
val labelAlpha = if (enabled) 1f else 0.38f
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha),
|
||||
)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
OverrideBadge(field)
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha),
|
||||
)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha),
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +926,8 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
* A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick.
|
||||
* [caption] is the field's own explanation, rendered directly under the control — the `described()`
|
||||
* idiom the other clients use, so a dropdown's guidance belongs to it instead of floating as a
|
||||
* loose paragraph between rows.
|
||||
* loose paragraph between rows. [field] is the overlay field this row writes, which is what its
|
||||
* override marker keys on.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -703,6 +935,7 @@ private fun <T> SettingDropdown(
|
||||
label: String,
|
||||
options: List<Pair<T, String>>,
|
||||
selected: T,
|
||||
field: String? = null,
|
||||
caption: String? = null,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
@@ -710,6 +943,7 @@ private fun <T> SettingDropdown(
|
||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OverrideBadge(field)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
|
||||
@@ -40,6 +40,12 @@ internal fun StatsOverlay(
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
codecLabel: String = "",
|
||||
/**
|
||||
* The settings profile this session resolved, appended to the first line when there is one —
|
||||
* the in-stream answer to "which profile am I on?", as on the other clients. Absent (the
|
||||
* common case: no profile) the line is exactly what it always was.
|
||||
*/
|
||||
profileName: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
@@ -56,13 +62,17 @@ internal fun StatsOverlay(
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
val profileTag = profileName?.let { " · $it" }.orEmpty()
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid), Color.White)
|
||||
statLine(compactLine(s, latValid) + profileTag, Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||
statLine(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
||||
Color.White,
|
||||
)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
|
||||
@@ -470,7 +470,10 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
StatsOverlay(
|
||||
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
|
||||
@@ -13,12 +13,14 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -48,9 +51,26 @@ fun SectionLabel(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of a host card's overflow menu. [startsSection] draws a divider above it, which is how
|
||||
* the profile actions ("Connect with: …", "Pin as card: …") stay legible next to the host actions
|
||||
* in one flat menu — Compose has no submenus, and the Windows client made the same call.
|
||||
*/
|
||||
data class HostMenuItem(
|
||||
val label: String,
|
||||
val startsSection: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||
* saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping
|
||||
* the card connects.
|
||||
*
|
||||
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
|
||||
* its default binding, drawn as a quiet chip — the card says what a tap will do. On a **pinned
|
||||
* card** ([profileProminent]) the host name is still the title, but the profile is the loud part,
|
||||
* because the pin exists to make that one combination a single tap.
|
||||
*/
|
||||
@Composable
|
||||
fun HostCard(
|
||||
@@ -63,6 +83,10 @@ fun HostCard(
|
||||
onForget: (() -> Unit)?,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onWake: (() -> Unit)? = null,
|
||||
profileLabel: String? = null,
|
||||
profileProminent: Boolean = false,
|
||||
accent: Color? = null,
|
||||
menuItems: List<HostMenuItem> = emptyList(),
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -106,6 +130,10 @@ fun HostCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (profileLabel != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ProfileChip(profileLabel, accent, prominent = profileProminent)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -116,7 +144,7 @@ fun HostCard(
|
||||
}
|
||||
}
|
||||
|
||||
if (onForget != null || onEdit != null || onWake != null) {
|
||||
if (onForget != null || onEdit != null || onWake != null || menuItems.isNotEmpty()) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -155,6 +183,16 @@ fun HostCard(
|
||||
},
|
||||
)
|
||||
}
|
||||
menuItems.forEach { item ->
|
||||
if (item.startsSection) HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(item.label) },
|
||||
onClick = {
|
||||
menu = false
|
||||
item.onClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +200,37 @@ fun HostCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a card connects with. Quiet on a bound host's own card (it is a note about what a tap
|
||||
* does); filled and tinted on a pinned card, where the profile IS the reason the card exists — the
|
||||
* accent field the schema reserves earns its keep here.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) {
|
||||
val tint = accent ?: MaterialTheme.colorScheme.primary
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
label,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
@Composable
|
||||
fun HostAvatar(name: String) {
|
||||
|
||||
@@ -42,6 +42,11 @@ data class ActiveSession(
|
||||
val handle: Long,
|
||||
val settings: io.unom.punktfunk.Settings,
|
||||
val clipboardSync: Boolean,
|
||||
/**
|
||||
* The settings profile this session resolved, if any — shown on the stats overlay's first line
|
||||
* so "which profile am I on?" is answerable from inside the stream, as on the other clients.
|
||||
*/
|
||||
val profileName: String? = null,
|
||||
)
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The profile model — the part of this feature that is wrong-or-right rather than pretty-or-ugly.
|
||||
* A profile is a named bundle of OVERRIDES, not a snapshot: an untouched field keeps following the
|
||||
* global live, a touched one is recorded even when it equals today's global (a pin), and the only
|
||||
* way back to inheriting is an explicit reset. These tests are the Kotlin twin of the Rust
|
||||
* `profiles.rs` suite, so the two can't drift.
|
||||
*
|
||||
* `sdk = [36]` for the same reason the screenshot tests pin it: Robolectric ships android-all jars
|
||||
* only up to API 36 while the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class ProfilesTest {
|
||||
private val base = Settings(
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
bitrateKbps = 20_000,
|
||||
codec = "hevc",
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
mouseMode = MouseMode.DESKTOP,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun overlayAppliesOnlyWhatItOverrides() {
|
||||
val empty = SettingsOverlay()
|
||||
assertTrue(empty.isEmpty())
|
||||
assertEquals(base, empty.apply(base))
|
||||
|
||||
val overlay = SettingsOverlay(
|
||||
width = 3840,
|
||||
height = 2160,
|
||||
hz = 120,
|
||||
bitrateKbps = 80_000,
|
||||
renderScale = 1.5,
|
||||
codec = "av1",
|
||||
hdrEnabled = false,
|
||||
compositor = 4,
|
||||
audioChannels = 6,
|
||||
micEnabled = true,
|
||||
touchMode = TouchMode.POINTER,
|
||||
mouseMode = MouseMode.CAPTURE,
|
||||
invertScroll = true,
|
||||
gamepad = 6,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
lowLatencyMode = false,
|
||||
)
|
||||
assertFalse(overlay.isEmpty())
|
||||
val out = overlay.apply(base)
|
||||
assertEquals(Triple(3840, 2160, 120), Triple(out.width, out.height, out.hz))
|
||||
assertEquals(80_000, out.bitrateKbps)
|
||||
assertEquals(1.5, out.renderScale, 0.0)
|
||||
assertEquals("av1", out.codec)
|
||||
assertFalse(out.hdrEnabled)
|
||||
assertEquals(4, out.compositor)
|
||||
assertEquals(6, out.audioChannels)
|
||||
assertTrue(out.micEnabled)
|
||||
assertEquals(TouchMode.POINTER, out.touchMode)
|
||||
assertEquals(MouseMode.CAPTURE, out.mouseMode)
|
||||
assertTrue(out.invertScroll)
|
||||
assertEquals(6, out.gamepad)
|
||||
assertEquals(StatsVerbosity.DETAILED, out.statsVerbosity)
|
||||
assertFalse(out.lowLatencyMode)
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverrideEqualToTheGlobalIsAPinThatSurvivesTheGlobalMoving() {
|
||||
val pin = SettingsOverlay(bitrateKbps = 20_000) // exactly what `base` says today
|
||||
assertFalse(pin.isEmpty())
|
||||
assertEquals(20_000, pin.apply(base.copy(bitrateKbps = 50_000)).bitrateKbps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absorbRecordsTheTouchedFieldOnly() {
|
||||
var o = SettingsOverlay()
|
||||
|
||||
// One control fires: before = what it was showing, after = what the user picked.
|
||||
var before = o.apply(base)
|
||||
o = o.absorb(before, before.copy(codec = "av1"))
|
||||
assertEquals("av1", o.codec)
|
||||
assertNull("nothing else may be recorded", o.bitrateKbps)
|
||||
|
||||
// Setting it BACK to the global's value is still an override — the pin case, and the whole
|
||||
// difference between this and diffing against the globals at save time.
|
||||
before = o.apply(base)
|
||||
o = o.absorb(before, before.copy(codec = "hevc"))
|
||||
assertEquals("hevc", o.codec)
|
||||
assertEquals("hevc", o.apply(base.copy(codec = "h264")).codec)
|
||||
|
||||
// Identical snapshots record nothing.
|
||||
before = o.apply(base)
|
||||
assertEquals(o, o.absorb(before, before))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearDropsOneOverride() {
|
||||
val o = SettingsOverlay(width = 3840, height = 2160, codec = "av1")
|
||||
assertEquals(setOf(SettingsOverlay.FIELD_RESOLUTION, "codec"), o.overridden())
|
||||
assertNull(o.clear("codec").codec)
|
||||
// Width and height are one control, so they reset together.
|
||||
val reset = o.clear(SettingsOverlay.FIELD_RESOLUTION)
|
||||
assertNull(reset.width)
|
||||
assertNull(reset.height)
|
||||
assertEquals(o, o.clear("no_such_field")) // unknown names are a no-op, never a crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun catalogRoundTripsAndPreservesWhatItCannotRepresent() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val game = newProfile("Game").copy(
|
||||
accent = "#ff8800",
|
||||
overrides = SettingsOverlay(
|
||||
width = 3840,
|
||||
height = 2160,
|
||||
hz = 120,
|
||||
// A codec string this build's picker can't show is still stored and still applied:
|
||||
// the host is the component that decides what it can encode.
|
||||
codec = "vvc-from-the-future",
|
||||
extra = mapOf("some_new_axis" to 7),
|
||||
),
|
||||
extra = mapOf("future_profile_key" to "kept"),
|
||||
)
|
||||
store.save(game)
|
||||
store.save(newProfile("Work"))
|
||||
|
||||
val loaded = store.byId(game.id)!!
|
||||
assertEquals("Game", loaded.name)
|
||||
assertEquals("#ff8800", loaded.accent)
|
||||
assertEquals("vvc-from-the-future", loaded.overrides.codec)
|
||||
assertEquals(3840, loaded.overrides.width)
|
||||
// The don't-clobber rule: an older build must not erase a newer one's keys by opening it.
|
||||
assertEquals(mapOf<String, Any>("some_new_axis" to 7), loaded.overrides.extra)
|
||||
assertEquals(mapOf<String, Any>("future_profile_key" to "kept"), loaded.extra)
|
||||
assertEquals("vvc-from-the-future", loaded.overrides.apply(base).codec)
|
||||
|
||||
// A profile that overrides nothing is the "inherits everything" one a create starts at.
|
||||
assertTrue(store.all().first { it.name == "Work" }.overrides.isEmpty())
|
||||
assertEquals(listOf("Game", "Work"), store.all().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePrefersIdsAndRefusesAmbiguity() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val work = newProfile("Work")
|
||||
val work2 = newProfile("work") // saved directly: the UI's name guard is what prevents this
|
||||
val game = newProfile("Game")
|
||||
listOf(work, work2, game).forEach(store::save)
|
||||
|
||||
assertEquals(ProfileResolution.FOUND, store.resolve(work.id).second)
|
||||
assertEquals(work.id, store.resolve(work.id).first!!.id)
|
||||
// Two profiles carry this name — refuse rather than pick whichever came first.
|
||||
assertEquals(ProfileResolution.AMBIGUOUS, store.resolve("Work").second)
|
||||
assertNull(store.resolve("Work").first)
|
||||
assertEquals(game.id, store.resolve("GAME").first!!.id) // names match case-insensitively
|
||||
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("nope").second)
|
||||
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("").second)
|
||||
|
||||
assertTrue(store.nameTaken("GAME"))
|
||||
assertFalse(store.nameTaken("GAME", except = game.id)) // renaming in place is allowed
|
||||
assertFalse(store.nameTaken("Travel"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profilePrecedenceIsOneOffThenBindingThenNone() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val work = newProfile("Work")
|
||||
val game = newProfile("Game")
|
||||
listOf(work, game).forEach(store::save)
|
||||
val bound = host().copy(profileId = work.id)
|
||||
|
||||
// A plain tap follows the binding…
|
||||
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
|
||||
// …a one-off wins over it, by id or by unique name, and never rebinds anything…
|
||||
assertEquals(game.id, store.resolveFor(bound, oneOff = game.id)!!.id)
|
||||
assertEquals(game.id, store.resolveFor(bound, oneOff = "game")!!.id)
|
||||
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
|
||||
// …and the empty reference is a real choice — "force the global defaults" — not "unset".
|
||||
assertNull(store.resolveFor(bound, oneOff = ""))
|
||||
// An unbound host is today's behaviour: the globals.
|
||||
assertNull(store.resolveFor(host(), oneOff = null))
|
||||
assertNull(store.resolveFor(null, oneOff = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeletedProfileLeavesNoErrorBehind() {
|
||||
val store = ProfileStore(RuntimeEnvironment.getApplication())
|
||||
val work = newProfile("Work")
|
||||
store.save(work)
|
||||
val h = host().copy(profileId = work.id, pinnedProfileIds = listOf(work.id, work.id))
|
||||
assertEquals(1, store.pinsFor(h).size) // a duplicate pin is one card, not two
|
||||
|
||||
store.delete(work.id)
|
||||
// A dangling binding resolves as "no profile" — never an error, never a blocked connect —
|
||||
// and its pinned card simply stops rendering.
|
||||
assertNull(store.resolveFor(h, oneOff = null))
|
||||
assertTrue(store.pinsFor(h).isEmpty())
|
||||
assertEquals(base, base.effectiveFor(store.resolveFor(h, oneOff = null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintedIdsAreWellFormed() {
|
||||
val id = newProfileId()
|
||||
assertEquals(12, id.length)
|
||||
assertTrue(id.all { it.isDigit() || it in 'a'..'f' })
|
||||
assertNotEquals(id, newProfileId())
|
||||
}
|
||||
|
||||
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
|
||||
}
|
||||
@@ -68,6 +68,9 @@ class ScreenshotTest {
|
||||
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Input)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsProfile() = shootRoot("settings-profile") { SettingsProfileScene() }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@@ -19,10 +19,12 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
@@ -35,8 +37,12 @@ import io.unom.punktfunk.SettingsCategory
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
import io.unom.punktfunk.SettingsOverlay
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.newProfile
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
|
||||
// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced
|
||||
@@ -49,10 +55,20 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
private data class MockHost(val name: String, val address: String, val status: HostStatus)
|
||||
private data class MockHost(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val status: HostStatus,
|
||||
val profile: String? = null,
|
||||
val pin: String? = null,
|
||||
val accent: Color? = null,
|
||||
)
|
||||
|
||||
private val SAVED = listOf(
|
||||
MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED),
|
||||
MockHost(
|
||||
"Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED,
|
||||
profile = "Game", pin = "Work", accent = Color(0xFFFF8A4C),
|
||||
),
|
||||
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
|
||||
)
|
||||
private val DISCOVERED = listOf(
|
||||
@@ -87,8 +103,32 @@ internal fun HostsScene() {
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
items(SAVED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||
// A pinned card is its OWN grid cell right after its host — the same flat list the
|
||||
// connect screen builds, not a second card crammed into the host's cell.
|
||||
SAVED.forEach { h ->
|
||||
item {
|
||||
HostCard(
|
||||
h.name, h.address, h.status, enabled = true,
|
||||
onConnect = {}, onForget = {}, onEdit = {},
|
||||
// The bound profile is a quiet chip: the card says what a tap will do.
|
||||
profileLabel = h.profile,
|
||||
accent = h.accent,
|
||||
menuItems = listOf(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {},
|
||||
HostMenuItem("Connect with: Game") {},
|
||||
),
|
||||
)
|
||||
}
|
||||
if (h.pin != null) {
|
||||
item {
|
||||
HostCard(
|
||||
h.name, h.address, h.status, enabled = true,
|
||||
onConnect = {}, onForget = null,
|
||||
profileLabel = h.pin, profileProminent = true, accent = h.accent,
|
||||
menuItems = listOf(HostMenuItem("Unpin card", startsSection = true) {}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -142,6 +182,36 @@ internal fun SettingsCategoryScene(category: SettingsCategory) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same settings surface in a PROFILE's scope: the scope chips with "Game" selected, only
|
||||
* profileable rows, every row showing the effective value, and the overridden ones carrying their
|
||||
* marker and reset. One settings UI, two layers — this shot is what proves it stayed one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsProfileScene() {
|
||||
val store = ProfileStore(LocalContext.current)
|
||||
val profile = remember {
|
||||
val p = newProfile("Game").copy(
|
||||
accent = "#FF8A4C",
|
||||
// A representative mix: a resolution and refresh the profile pins, and a codec — the
|
||||
// rest of the page keeps following the defaults, visibly unmarked.
|
||||
overrides = SettingsOverlay(width = 3840, height = 2160, hz = 120, codec = "h264"),
|
||||
)
|
||||
store.save(p)
|
||||
store.save(newProfile("Work"))
|
||||
p
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
SettingsScreen(
|
||||
initial = SHOT_SETTINGS,
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
initialCategory = SettingsCategory.Display,
|
||||
initialProfileId = profile.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
|
||||
@Composable
|
||||
internal fun TrustDialog() {
|
||||
|
||||
Reference in New Issue
Block a user