feat(android): console UI, wake-on-LAN wait-until-up, host edit + TV/tablet polish
Bring the Android client to parity with Apple's gamepad experience and finish Wake-on-LAN. - Console/gamepad home: host carousel, aurora chrome, mTLS game-library coverflow, and an input-aware legend that switches between gamepad face buttons and a TV-remote select-ring + arrows based on the last-used input. - Wake-on-LAN: the fire-and-forget send is upgraded to wait-until-up (WakeController/WakeOverlay: resend + mDNS poll, 90s timeout, cancel/retry, fingerprint-matched so a host that cold-boots onto a new DHCP IP still connects), plus host edit (touch dialog + console form) with an auto-filled MAC. - Android TV: brand banner (android:banner), density-aware console scaling, D-pad/ remote nav (Up = Settings, Down or the pad Select button = host Options), emergency stream-exit chord, and 120Hz console refresh. - Touch UI: settings split into subpages with a tablet NavigationRail, axis-aware tab animation (horizontal on phones, vertical on the tablet rail), animated settings navigation, and a licenses screen with a back button + the real workspace version (read from Cargo.toml). - Vector Lock/controller icons (no emoji); bundled Geist font. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
||||||
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||||
#
|
#
|
||||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
"$RUSTUP" target add aarch64-linux-android x86_64-linux-android
|
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||||
|
|
||||||
- name: Android SDK
|
- name: Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
||||||
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
|
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||||
|
|
||||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation).
|
|||||||
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
||||||
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
||||||
|
|
||||||
Built for `arm64-v8a` + `x86_64`.
|
Built for `arm64-v8a` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the
|
||||||
|
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
|
||||||
|
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
|||||||
|
|
||||||
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
||||||
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||||
a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and
|
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
|
||||||
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,34 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applicationId = "io.unom.punktfunk"
|
applicationId = "io.unom.punktfunk"
|
||||||
minSdk = 31
|
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 9–11);
|
||||||
|
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
||||||
|
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
||||||
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||||
versionCode = vCode?.toInt() ?: 1
|
versionCode = vCode?.toInt() ?: 1
|
||||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||||
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
// the single source of truth — so an on-device build shows the real current version, not a
|
||||||
|
// stale placeholder.
|
||||||
|
val workspaceVersion = runCatching {
|
||||||
|
project.rootProject.file("../../Cargo.toml").readLines()
|
||||||
|
.dropWhile { !it.trim().startsWith("[workspace.package]") }
|
||||||
|
.firstOrNull { it.trim().startsWith("version") }
|
||||||
|
?.substringAfter('=')?.trim()?.trim('"')
|
||||||
|
}.getOrNull()
|
||||||
|
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
|
||||||
|
?: workspaceVersion ?: "0.0.0"
|
||||||
|
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
|
||||||
|
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
|
||||||
|
// userspace, and because this app carries native code, Google Play (and a sideload installer)
|
||||||
|
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
|
||||||
|
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
|
||||||
|
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
|
||||||
|
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -97,9 +117,18 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.foundation:foundation")
|
implementation("androidx.compose.foundation:foundation")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
|
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
|
||||||
|
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
|
||||||
|
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
|
||||||
|
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
|
||||||
|
implementation("dev.chrisbanes.haze:haze:1.6.0")
|
||||||
|
|
||||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
||||||
// implementation("androidx.tv:tv-material:1.1.0")
|
// implementation("androidx.tv:tv-material:1.1.0")
|
||||||
// The manifest already declares leanback so the scaffold installs on TV.
|
// The manifest already declares leanback so the scaffold installs on TV.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:appCategory="game"
|
android:appCategory="game"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -2,40 +2,62 @@ package io.unom.punktfunk
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.models.Tab
|
import io.unom.punktfunk.models.Tab
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(forceGamepadUi: Boolean = false) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||||
|
|
||||||
|
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||||
|
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||||
|
val tv = remember { isTvDevice(context) }
|
||||||
|
val controllerConnected by rememberControllerConnected()
|
||||||
|
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = streamHandle != 0L,
|
targetState = streamHandle != 0L,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
@@ -46,46 +68,154 @@ fun App() {
|
|||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||||
|
} else if (gamepadUi) {
|
||||||
|
GamepadShell(
|
||||||
|
settings = settings,
|
||||||
|
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||||
|
onConnected = { streamHandle = it },
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Scaffold(
|
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||||
bottomBar = {
|
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
|
||||||
NavigationBar {
|
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
|
||||||
Tab.entries.forEach { t ->
|
// Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone),
|
||||||
NavigationBarItem(
|
// vertically with the side rail (tablet), so the motion tracks the direction you moved.
|
||||||
selected = tab == t,
|
val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical ->
|
||||||
onClick = { tab = t },
|
AnimatedContent(
|
||||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
targetState = tab,
|
||||||
label = { Text(t.label) },
|
transitionSpec = {
|
||||||
)
|
val forward = targetState.ordinal > initialState.ordinal
|
||||||
}
|
when {
|
||||||
}
|
vertical && forward ->
|
||||||
},
|
slideInVertically { it } + fadeIn() togetherWith
|
||||||
) { innerPadding ->
|
slideOutVertically { -it } + fadeOut()
|
||||||
Box(Modifier.fillMaxSize().padding(innerPadding)) {
|
vertical ->
|
||||||
AnimatedContent(
|
slideInVertically { -it } + fadeIn() togetherWith
|
||||||
targetState = tab,
|
slideOutVertically { it } + fadeOut()
|
||||||
transitionSpec = {
|
forward ->
|
||||||
if (targetState.ordinal > initialState.ordinal) {
|
|
||||||
slideInHorizontally { it } + fadeIn() togetherWith
|
slideInHorizontally { it } + fadeIn() togetherWith
|
||||||
slideOutHorizontally { -it } + fadeOut()
|
slideOutHorizontally { -it } + fadeOut()
|
||||||
} else {
|
else ->
|
||||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||||
slideOutHorizontally { it } + fadeOut()
|
slideOutHorizontally { it } + fadeOut()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "TabTransition"
|
||||||
|
) { targetTab ->
|
||||||
|
when (targetTab) {
|
||||||
|
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
||||||
|
Tab.Settings -> SettingsScreen(
|
||||||
|
initial = settings,
|
||||||
|
onChange = { settings = it; settingsStore.save(it) },
|
||||||
|
onBack = { tab = Tab.Connect },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
if (maxWidth >= 600.dp && maxHeight >= 480.dp) {
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
NavigationRail(Modifier.fillMaxHeight()) {
|
||||||
|
Spacer(Modifier.weight(1f)) // centre the rail items vertically
|
||||||
|
Tab.entries.forEach { t ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
// The rail handles its own insets; the content pane insets itself (the screens
|
||||||
|
// don't, since they used to rely on the Scaffold's padding).
|
||||||
|
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
Tab.entries.forEach { t ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = "TabTransition"
|
) { innerPadding ->
|
||||||
) { targetTab ->
|
Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) }
|
||||||
when (targetTab) {
|
|
||||||
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
|
|
||||||
Tab.Settings -> SettingsScreen(
|
|
||||||
initial = settings,
|
|
||||||
onChange = { settings = it; settingsStore.save(it) },
|
|
||||||
onBack = { tab = Tab.Connect },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which console screen the gamepad shell is showing. */
|
||||||
|
private enum class GamepadScreen { Home, Settings, Library }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||||
|
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
|
||||||
|
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadShell(
|
||||||
|
settings: Settings,
|
||||||
|
onSettingsChange: (Settings) -> Unit,
|
||||||
|
onConnected: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||||
|
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||||
|
|
||||||
|
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||||
|
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||||
|
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||||
|
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
|
||||||
|
// wanted — one uniform factor across text, cards, spacing, and insets.
|
||||||
|
val isTv = remember { isTvDevice(context) }
|
||||||
|
val baseDensity = LocalDensity.current
|
||||||
|
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
|
||||||
|
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||||
|
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||||
|
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
|
||||||
|
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
|
||||||
|
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
|
||||||
|
// the content behind it fades.
|
||||||
|
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
|
||||||
|
when (s) {
|
||||||
|
GamepadScreen.Home -> ConnectScreen(
|
||||||
|
settings = settings,
|
||||||
|
onConnected = onConnected,
|
||||||
|
gamepadUi = true,
|
||||||
|
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||||
|
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||||
|
navGate = s == screen,
|
||||||
|
)
|
||||||
|
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||||
|
initial = settings,
|
||||||
|
onChange = onSettingsChange,
|
||||||
|
onBack = { screen = GamepadScreen.Home },
|
||||||
|
navActive = s == screen,
|
||||||
|
)
|
||||||
|
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||||
|
LibraryScreen(
|
||||||
|
host = host,
|
||||||
|
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||||
|
navActive = s == screen,
|
||||||
|
)
|
||||||
|
} ?: run { screen = GamepadScreen.Home }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||||
|
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
import io.unom.punktfunk.kit.security.KnownHost
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||||
import io.unom.punktfunk.models.PendingTrust
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -320,32 +323,75 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||||
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
|
||||||
|
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
|
||||||
|
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
|
||||||
|
* Apple client's edit form.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RenameHostDialog(
|
internal fun EditHostDialog(
|
||||||
target: KnownHost,
|
target: KnownHost,
|
||||||
onRename: (String) -> Unit,
|
suggestedMacs: List<String>,
|
||||||
|
onSave: (KnownHost) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var newName by remember(target) { mutableStateOf(target.name) }
|
var name by remember(target) { mutableStateOf(target.name) }
|
||||||
|
var address by remember(target) { mutableStateOf(target.address) }
|
||||||
|
var port by remember(target) { mutableStateOf(target.port.toString()) }
|
||||||
|
var mac by remember(target) {
|
||||||
|
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||||
|
}
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Rename host") },
|
title = { Text("Edit host") },
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
Column(
|
||||||
value = newName,
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
onValueChange = { newName = it },
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
label = { Text("Name") },
|
) {
|
||||||
placeholder = { Text(target.address) },
|
OutlinedTextField(
|
||||||
singleLine = true,
|
value = name,
|
||||||
)
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text(target.address) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text("Address") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mac,
|
||||||
|
onValueChange = { mac = it },
|
||||||
|
label = { Text("Wake-on-LAN MAC") },
|
||||||
|
placeholder = { Text("auto-filled when the host is seen") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = newName.isNotBlank(),
|
enabled = address.isNotBlank(),
|
||||||
onClick = { onRename(newName.trim()) },
|
onClick = {
|
||||||
|
onSave(
|
||||||
|
target.copy(
|
||||||
|
name = name.trim().ifEmpty { target.address },
|
||||||
|
address = address.trim(),
|
||||||
|
port = port.toIntOrNull() ?: target.port,
|
||||||
|
mac = KnownHostStore.parseMacs(mac),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
) { Text("Save") }
|
) { Text("Save") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
|
|||||||
@@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
fun ConnectScreen(
|
||||||
|
settings: Settings,
|
||||||
|
onConnected: (Long) -> Unit,
|
||||||
|
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
|
||||||
|
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
|
||||||
|
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||||
|
gamepadUi: Boolean = false,
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||||
|
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
@@ -124,6 +134,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
val identityStore = remember { IdentityStore(context) }
|
val identityStore = remember { IdentityStore(context) }
|
||||||
val knownHostStore = remember { KnownHostStore(context) }
|
val knownHostStore = remember { KnownHostStore(context) }
|
||||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
var savedHosts by remember { mutableStateOf(knownHostStore.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.
|
||||||
|
val waker = remember { WakeController(scope) }
|
||||||
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||||
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||||
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||||
@@ -156,8 +170,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||||
// A saved host whose label is being edited (the Rename dialog).
|
// A saved host being edited (name / address / port / MAC).
|
||||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
|
||||||
|
// carousel (the console counterpart of the touch host card's overflow menu).
|
||||||
|
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||||
@@ -184,25 +201,16 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
// straight through and it appears in the saved-hosts list.
|
// appears in the saved-hosts list.
|
||||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
val id = identity
|
val id = identity ?: run {
|
||||||
if (id == null) {
|
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
// Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it
|
|
||||||
// was online and it isn't currently advertising, fire a magic packet first — the connect's
|
|
||||||
// own timeout gives a woken host time to come up (harmless if it's already awake).
|
|
||||||
knownHostStore.get(targetHost, targetPort)?.mac
|
|
||||||
?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } }
|
|
||||||
?.let { macs ->
|
|
||||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
|
||||||
}
|
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
@@ -222,6 +230,47 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
|
||||||
|
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
|
||||||
|
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
|
||||||
|
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
|
||||||
|
// dial straight through.
|
||||||
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
|
if (identity == null) {
|
||||||
|
status = "Identity not ready yet — try again in a moment"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val kh = knownHostStore.get(targetHost, targetPort)
|
||||||
|
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
|
||||||
|
// dial its live address rather than the stale saved one.
|
||||||
|
fun liveAdvert(): DiscoveredHost? =
|
||||||
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
|
if (macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
|
waker.start(
|
||||||
|
hostName = name,
|
||||||
|
connectsAfter = true,
|
||||||
|
macs = macs,
|
||||||
|
lastIp = targetHost,
|
||||||
|
isOnline = { liveAdvert() != null },
|
||||||
|
onOnline = {
|
||||||
|
val live = liveAdvert()
|
||||||
|
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
||||||
|
// point at the live one, then dial there.
|
||||||
|
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||||
|
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
|
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||||
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||||
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||||
@@ -304,7 +353,62 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
|
|
||||||
var showManualSheet by remember { mutableStateOf(false) }
|
var showManualSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
if (gamepadUi) {
|
||||||
|
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||||
|
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||||
|
val tiles = buildList {
|
||||||
|
savedHosts.forEach { kh ->
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "saved-${kh.address}:${kh.port}",
|
||||||
|
title = kh.name,
|
||||||
|
subtitle = "${kh.address}:${kh.port}",
|
||||||
|
filled = true,
|
||||||
|
online = discovered.any { it.host == kh.address && it.port == kh.port },
|
||||||
|
paired = kh.paired,
|
||||||
|
knownHost = kh,
|
||||||
|
activate = { connect(kh.address, kh.port) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
discoveredUnsaved.forEach { dh ->
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "disc-${dh.host}:${dh.port}",
|
||||||
|
title = dh.name,
|
||||||
|
subtitle = "${dh.host}:${dh.port}",
|
||||||
|
online = true,
|
||||||
|
activate = { connect(dh.host, dh.port, dh) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
HomeTile(
|
||||||
|
id = "add",
|
||||||
|
title = "Add Host",
|
||||||
|
subtitle = "Register a host by address",
|
||||||
|
isAdd = true,
|
||||||
|
activate = { showManualSheet = true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GamepadHome(
|
||||||
|
tiles = tiles,
|
||||||
|
libraryEnabled = settings.libraryEnabled,
|
||||||
|
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
|
||||||
|
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
|
||||||
|
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
|
||||||
|
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||||
|
// console home is cross-fading out.
|
||||||
|
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||||
|
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
|
||||||
|
onActivate = { it.activate() },
|
||||||
|
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -385,13 +489,22 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
knownHostStore.remove(kh.address, kh.port)
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
},
|
},
|
||||||
onRename = { renameTarget = kh },
|
onEdit = { editTarget = kh },
|
||||||
// Explicit wake: offered only when the host is offline and we have a MAC to
|
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||||
// target (a tap-to-connect already auto-wakes an offline saved host).
|
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||||
onWake = if (kh.mac.isNotEmpty() &&
|
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||||
discovered.none { it.host == kh.address && it.port == kh.port }
|
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||||
) {
|
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
|
||||||
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } }
|
{
|
||||||
|
waker.start(
|
||||||
|
hostName = kh.name,
|
||||||
|
connectsAfter = false,
|
||||||
|
macs = kh.mac,
|
||||||
|
lastIp = kh.address,
|
||||||
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
|
onOnline = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -451,80 +564,134 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showManualSheet) {
|
if (showManualSheet) {
|
||||||
AddHostSheet(
|
if (gamepadUi) {
|
||||||
hostName = hostName,
|
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||||
onHostNameChange = { hostName = it },
|
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
|
||||||
host = host,
|
GamepadAddHostScreen(
|
||||||
onHostChange = { host = it },
|
onAdd = { n, addr, p ->
|
||||||
port = port,
|
showManualSheet = false
|
||||||
onPortChange = { port = it },
|
connect(addr, p, manualName = n)
|
||||||
connecting = connecting,
|
|
||||||
modeLabel = "$w×$h@$hz",
|
|
||||||
onDismiss = { showManualSheet = false },
|
|
||||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingTrust?.let { pt ->
|
|
||||||
when (pt.kind) {
|
|
||||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
|
||||||
pt = pt,
|
|
||||||
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
|
|
||||||
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
|
||||||
onDismiss = { pendingTrust = null },
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
|
|
||||||
pt = pt,
|
|
||||||
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
|
||||||
onDismiss = { pendingTrust = null },
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
|
|
||||||
pt = pt,
|
|
||||||
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
|
|
||||||
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
|
||||||
onDismiss = { pendingTrust = null },
|
|
||||||
)
|
|
||||||
PendingTrust.Kind.PAIR -> PairPinDialog(
|
|
||||||
pt = pt,
|
|
||||||
identity = identity,
|
|
||||||
onPaired = { fp ->
|
|
||||||
// Verified host fp — save as a paired known host, then connect pinned.
|
|
||||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
|
||||||
savedHosts = knownHostStore.all()
|
|
||||||
pendingTrust = null
|
|
||||||
doConnect(pt.host, pt.port, pt.name, fp)
|
|
||||||
},
|
},
|
||||||
onDismiss = { pendingTrust = null },
|
onDismiss = { showManualSheet = false },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AddHostSheet(
|
||||||
|
hostName = hostName,
|
||||||
|
onHostNameChange = { hostName = it },
|
||||||
|
host = host,
|
||||||
|
onHostChange = { host = it },
|
||||||
|
port = port,
|
||||||
|
onPortChange = { port = it },
|
||||||
|
connecting = connecting,
|
||||||
|
modeLabel = "$w×$h@$hz",
|
||||||
|
onDismiss = { showManualSheet = false },
|
||||||
|
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingTrust?.let { pt ->
|
||||||
|
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||||
|
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||||
|
val onSavePaired = { fp: String ->
|
||||||
|
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
pendingTrust = null
|
||||||
|
doConnect(pt.host, pt.port, pt.name, fp)
|
||||||
|
}
|
||||||
|
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 })
|
||||||
|
PendingTrust.Kind.FP_CHANGED ->
|
||||||
|
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
|
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
|
PendingTrust.Kind.REQUEST_ACCESS ->
|
||||||
|
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||||
|
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||||
|
PendingTrust.Kind.PAIR ->
|
||||||
|
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||||
|
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
awaiting?.let { req ->
|
awaiting?.let { req ->
|
||||||
AwaitingApprovalDialog(
|
val onCancel = {
|
||||||
hostLabel = req.target.name,
|
req.cancelled.set(true)
|
||||||
onCancel = {
|
awaiting = null
|
||||||
req.cancelled.set(true)
|
connecting = false
|
||||||
awaiting = null
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
connecting = false
|
}
|
||||||
discovery.start() // the request may still be pending on the host; keep scanning
|
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
|
||||||
|
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||||
|
optionsTarget?.let { kh ->
|
||||||
|
val offline = discovered.none { kh.matches(it) }
|
||||||
|
GamepadHostOptionsDialog(
|
||||||
|
hostName = kh.name,
|
||||||
|
canWake = kh.mac.isNotEmpty() && offline,
|
||||||
|
onWake = {
|
||||||
|
optionsTarget = null
|
||||||
|
waker.start(
|
||||||
|
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||||
|
isOnline = { discovered.any { kh.matches(it) } },
|
||||||
|
onOnline = {},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||||
|
// so a TV remote reaches the library here instead of via the Y face button.
|
||||||
|
onLibrary = if (settings.libraryEnabled) {
|
||||||
|
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onEdit = { optionsTarget = null; editTarget = kh },
|
||||||
|
onForget = {
|
||||||
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
optionsTarget = null
|
||||||
|
},
|
||||||
|
onDismiss = { optionsTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renameTarget?.let { kh ->
|
editTarget?.let { kh ->
|
||||||
RenameHostDialog(
|
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||||
target = kh,
|
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||||
onRename = { newName ->
|
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||||
knownHostStore.rename(kh.address, kh.port, newName)
|
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||||
savedHosts = knownHostStore.all()
|
knownHostStore.update(kh.address, kh.port, updated)
|
||||||
renameTarget = null
|
savedHosts = knownHostStore.all()
|
||||||
},
|
editTarget = null
|
||||||
onDismiss = { renameTarget = null },
|
}
|
||||||
)
|
if (gamepadUi) {
|
||||||
|
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||||
|
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||||
|
GamepadAddHostScreen(
|
||||||
|
onAdd = { _, _, _ -> },
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
|
editHost = kh,
|
||||||
|
suggestedMacs = suggested,
|
||||||
|
onSave = onSaveHost,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EditHostDialog(
|
||||||
|
target = kh,
|
||||||
|
suggestedMacs = suggested,
|
||||||
|
onSave = onSaveHost,
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||||
|
WakeOverlay(waker, gamepadUi)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.hardware.input.InputManager
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
|
val canRumble = deviceHasVibrator(dev)
|
||||||
if (canRumble) {
|
if (canRumble) {
|
||||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||||
} else {
|
} else {
|
||||||
@@ -318,11 +319,27 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||||
|
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
dev.vibrator.hasVibrator()
|
||||||
|
}
|
||||||
|
|
||||||
private fun testRumble(dev: InputDevice) {
|
private fun testRumble(dev: InputDevice) {
|
||||||
val vm = dev.vibratorManager
|
|
||||||
if (vm.vibratorIds.isEmpty()) return
|
|
||||||
runCatching {
|
runCatching {
|
||||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
val vm = dev.vibratorManager
|
||||||
|
if (vm.vibratorIds.isEmpty()) return
|
||||||
|
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) return
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(300, 200))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
|
||||||
|
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
|
||||||
|
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
|
||||||
|
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
|
||||||
|
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
|
||||||
|
|
||||||
|
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
|
||||||
|
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
|
||||||
|
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
|
||||||
|
private const val KB_ROWS = 5
|
||||||
|
|
||||||
|
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadAddHostScreen(
|
||||||
|
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
|
||||||
|
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
|
||||||
|
editHost: KnownHost? = null,
|
||||||
|
suggestedMacs: List<String> = emptyList(),
|
||||||
|
onSave: ((KnownHost) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isTv = remember { isTvDevice(context) }
|
||||||
|
val isEdit = editHost != null
|
||||||
|
val title = if (isEdit) "Edit Host" else "Add Host"
|
||||||
|
val actionLabel = if (isEdit) "Save" else "Add Host"
|
||||||
|
var name by remember { mutableStateOf(editHost?.name ?: "") }
|
||||||
|
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||||
|
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||||
|
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||||
|
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||||
|
fun commit() {
|
||||||
|
if (isEdit && editHost != null && onSave != null) {
|
||||||
|
onSave(
|
||||||
|
editHost.copy(
|
||||||
|
name = name.trim().ifEmpty { editHost.address },
|
||||||
|
address = address.trim(),
|
||||||
|
port = port.toIntOrNull() ?: editHost.port,
|
||||||
|
mac = KnownHostStore.parseMacs(mac),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
|
||||||
|
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
|
||||||
|
// where the phone's own soft keyboard needs a touch a pad can't provide.
|
||||||
|
if (isTv) {
|
||||||
|
TvAddHostForm(
|
||||||
|
title = title, actionLabel = actionLabel,
|
||||||
|
name = name, onName = { name = it },
|
||||||
|
address = address, onAddress = { address = it },
|
||||||
|
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
|
||||||
|
mac = if (isEdit) mac else null, onMac = { mac = it },
|
||||||
|
canAdd = canAdd,
|
||||||
|
onAdd = { commit() },
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var focus by remember { mutableIntStateOf(1) } // start on Address
|
||||||
|
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
|
||||||
|
var kbRow by remember { mutableIntStateOf(1) }
|
||||||
|
var kbCol by remember { mutableIntStateOf(0) }
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
val fields = buildList {
|
||||||
|
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
|
||||||
|
add(Field("address", "Address", address, "IP or hostname"))
|
||||||
|
add(Field("port", "Port", port, "9777"))
|
||||||
|
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||||
|
}
|
||||||
|
val actionIndex = fields.size // the Save/Add action sits just after the last field
|
||||||
|
|
||||||
|
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
|
||||||
|
fun closeKeyboard() { editing = null }
|
||||||
|
fun editField(id: String, transform: (String) -> String) {
|
||||||
|
when (id) {
|
||||||
|
"name" -> name = transform(name)
|
||||||
|
"address" -> address = transform(address)
|
||||||
|
"port" -> port = transform(port).take(5)
|
||||||
|
"mac" -> mac = transform(mac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun allowed(id: String, c: Char): Boolean = when (id) {
|
||||||
|
"port" -> c.isDigit()
|
||||||
|
"address" -> c != ' '
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
fun activateField() {
|
||||||
|
if (focus == actionIndex) {
|
||||||
|
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||||
|
} else {
|
||||||
|
openKeyboard(fields[focus].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun pressKey() {
|
||||||
|
val id = editing ?: return
|
||||||
|
if (kbRow < KB_ACTIONS_ROW) {
|
||||||
|
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
|
||||||
|
if (allowed(id, c)) editField(id) { it + c }
|
||||||
|
} else when (kbCol) {
|
||||||
|
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
|
||||||
|
1 -> editField(id) { it.dropLast(1) }
|
||||||
|
else -> closeKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = { dir ->
|
||||||
|
if (editing == null) {
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||||
|
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||||
|
NavDir.LEFT -> if (kbCol > 0) kbCol--
|
||||||
|
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { if (editing == null) activateField() else pressKey() },
|
||||||
|
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
|
||||||
|
onSecondary = { if (editing != null) closeKeyboard() },
|
||||||
|
)
|
||||||
|
|
||||||
|
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
|
||||||
|
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
|
||||||
|
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
|
||||||
|
val typeHints = listOf(
|
||||||
|
PadGlyph.hint('A', "Type") { pressKey() },
|
||||||
|
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
|
||||||
|
PadGlyph.hint('B', "Done") { closeKeyboard() },
|
||||||
|
)
|
||||||
|
val sideBySide = landscape && editing != null
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
if (sideBySide) {
|
||||||
|
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
|
||||||
|
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||||
|
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ConsoleHeader(title, horizontalInset = false)
|
||||||
|
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
|
||||||
|
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||||
|
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||||
|
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||||
|
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ConsoleHeader(title, horizontalInset = false)
|
||||||
|
if (editing == null && !landscape) {
|
||||||
|
Text(
|
||||||
|
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||||
|
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||||
|
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||||
|
}
|
||||||
|
if (editing != null) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
|
||||||
|
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
|
||||||
|
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
|
||||||
|
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||||
|
Box(
|
||||||
|
Modifier.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(
|
||||||
|
if (editing != null) {
|
||||||
|
typeHints
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
PadGlyph.hint('A', "Select") { activateField() },
|
||||||
|
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
|
||||||
|
* custom keyboard or input probes — the native focus engine moves between fields and the Add button,
|
||||||
|
* and focusing a field pops the OS keyboard. B backs out.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TvAddHostForm(
|
||||||
|
title: String,
|
||||||
|
actionLabel: String,
|
||||||
|
name: String,
|
||||||
|
onName: (String) -> Unit,
|
||||||
|
address: String,
|
||||||
|
onAddress: (String) -> Unit,
|
||||||
|
port: String,
|
||||||
|
onPort: (String) -> Unit,
|
||||||
|
mac: String?, // non-null only in edit mode
|
||||||
|
onMac: (String) -> Unit,
|
||||||
|
canAdd: Boolean,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||||
|
.widthIn(max = 720.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name, onValueChange = onName, singleLine = true,
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address, onValueChange = onAddress, singleLine = true,
|
||||||
|
label = { Text("Address") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port, onValueChange = onPort, singleLine = true,
|
||||||
|
label = { Text("Port") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (mac != null) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mac, onValueChange = onMac, singleLine = true,
|
||||||
|
label = { Text("Wake-on-LAN MAC") },
|
||||||
|
placeholder = { Text("auto-filled when the host is seen") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
f.value.ifEmpty { f.placeholder },
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||||
|
.padding(vertical = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyboardGrid(
|
||||||
|
cursorRow: Int,
|
||||||
|
cursorCol: Int,
|
||||||
|
compact: Boolean,
|
||||||
|
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||||
|
onKey: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(20.dp)
|
||||||
|
val gap = if (compact) 5.dp else 7.dp
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(Color(0x1FFFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||||
|
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(gap),
|
||||||
|
) {
|
||||||
|
KB_CHAR_ROWS.forEachIndexed { r, chars ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||||
|
chars.forEachIndexed { c, ch ->
|
||||||
|
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||||
|
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
|
||||||
|
Keycap("⌫", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
|
||||||
|
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(if (compact) 34.dp else 44.dp)
|
||||||
|
.clip(RoundedCornerShape(9.dp))
|
||||||
|
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (focused) Color.Black else Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.SportsEsports
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||||
|
// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a
|
||||||
|
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||||
|
// as a coherent mode rather than a set of themed pages.
|
||||||
|
|
||||||
|
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||||
|
private class AuroraBlob(
|
||||||
|
val color: Color,
|
||||||
|
val baseX: Float,
|
||||||
|
val baseY: Float,
|
||||||
|
val driftX: Float,
|
||||||
|
val driftY: Float,
|
||||||
|
val sx: Int,
|
||||||
|
val sy: Int,
|
||||||
|
val phase: Float,
|
||||||
|
val radiusFrac: Float,
|
||||||
|
val alpha: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val auroraBlobs = listOf(
|
||||||
|
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||||
|
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||||
|
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||||
|
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||||
|
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||||
|
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "aurora")
|
||||||
|
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||||
|
// so the field never visibly jumps when the animation restarts.
|
||||||
|
val angle by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = (2 * PI).toFloat(),
|
||||||
|
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||||
|
label = "angle",
|
||||||
|
)
|
||||||
|
Canvas(modifier) {
|
||||||
|
drawRect(Color.Black)
|
||||||
|
val span = max(size.width, size.height)
|
||||||
|
for (b in auroraBlobs) {
|
||||||
|
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||||
|
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||||
|
val r = span * b.radiusFrac
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||||
|
center = Offset(cx, cy),
|
||||||
|
radius = r,
|
||||||
|
),
|
||||||
|
center = Offset(cx, cy),
|
||||||
|
radius = r,
|
||||||
|
blendMode = BlendMode.Plus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cinematic vignette: pool light centre, sink the corners.
|
||||||
|
drawRect(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||||
|
center = Offset(size.width / 2, size.height / 2),
|
||||||
|
radius = span * 0.92f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||||
|
drawRect(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||||
|
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||||
|
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||||
|
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||||
|
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||||
|
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||||
|
Canvas(modifier) {
|
||||||
|
val span = max(size.width, size.height)
|
||||||
|
drawRect(Color(0xFF131126))
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||||
|
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||||
|
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||||
|
radius = span * 0.7f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact inset every console screen places its floating legend at (bottom-start), so the legend
|
||||||
|
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||||
|
* it cross-fades between screens.
|
||||||
|
*/
|
||||||
|
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||||
|
|
||||||
|
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||||
|
val ConsoleEdgeInset = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading every console screen uses — one style, one inset, so titles line up across Home /
|
||||||
|
* Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||||
|
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||||
|
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||||
|
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
|
||||||
|
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
|
||||||
|
* controller can still drive the console UI (and reach Settings to switch it off).
|
||||||
|
*/
|
||||||
|
class GamepadHint(
|
||||||
|
val glyph: Char,
|
||||||
|
val color: Color,
|
||||||
|
val text: String,
|
||||||
|
val onClick: (() -> Unit)? = null,
|
||||||
|
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
||||||
|
// for a TV remote, which has no A/B/X/Y.
|
||||||
|
val select: Boolean = false,
|
||||||
|
// Render as the gamepad Select/View button (a small capsule).
|
||||||
|
val viewButton: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
|
||||||
|
object PadGlyph {
|
||||||
|
val A = Color(0xFF6BBE45)
|
||||||
|
val B = Color(0xFFD14B4B)
|
||||||
|
val X = Color(0xFF4B7BD1)
|
||||||
|
val Y = Color(0xFFE0B23C)
|
||||||
|
fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint(
|
||||||
|
glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||||
|
@Composable
|
||||||
|
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
glyph.toString(),
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = (size.value * 0.52f).sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||||
|
@Composable
|
||||||
|
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */
|
||||||
|
@Composable
|
||||||
|
private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||||
|
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = size * 0.74f, height = size * 0.46f)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
|
||||||
|
* translucent pill so it floats over the aurora rather than dissolving into it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||||
|
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||||
|
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||||
|
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
|
||||||
|
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||||
|
val shape = RoundedCornerShape(50)
|
||||||
|
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||||
|
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||||
|
val frosted = if (hazeState != null) {
|
||||||
|
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||||
|
} else {
|
||||||
|
modifier.clip(shape).background(Color(0x8C14122A))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = frosted
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||||
|
) {
|
||||||
|
for (h in hints) {
|
||||||
|
val cb = h.onClick
|
||||||
|
val cell = if (cb != null) {
|
||||||
|
Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
when {
|
||||||
|
h.viewButton -> ViewButtonGlyph()
|
||||||
|
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||||
|
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||||
|
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
h.text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||||
|
@Composable
|
||||||
|
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(Color.White.copy(alpha = 0.08f))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SportsEsports,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.75f),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(7.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||||
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
|
||||||
|
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
|
||||||
|
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
|
||||||
|
|
||||||
|
/** One dialog action button. */
|
||||||
|
class DialogAction(
|
||||||
|
val label: String,
|
||||||
|
val primary: Boolean = false,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
|
||||||
|
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
|
||||||
|
* up, via ConnectScreen's `navActive`). B → [onDismiss].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
actions: List<DialogAction>,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||||
|
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||||
|
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
||||||
|
)
|
||||||
|
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
|
||||||
|
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
|
||||||
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(24.dp)
|
||||||
|
.widthIn(max = 520.dp)
|
||||||
|
.heightIn(max = maxCardHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF01A1730))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
// The body scrolls; the title above and the buttons below stay pinned + always visible.
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
actions.forEachIndexed { i, a ->
|
||||||
|
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
val bg = when {
|
||||||
|
focused -> Color(0xFF6656F2)
|
||||||
|
primary -> Color(0x336656F2)
|
||||||
|
else -> Color(0x14FFFFFF)
|
||||||
|
}
|
||||||
|
val fg = when {
|
||||||
|
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||||
|
focused -> Color.White
|
||||||
|
primary -> Color(0xFF8678F5)
|
||||||
|
else -> Color.White.copy(alpha = 0.85f)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(bg)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 13.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body text helper — a dimmed paragraph. */
|
||||||
|
@Composable
|
||||||
|
private fun DialogText(text: String) {
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||||
|
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||||
|
* the touch host card's overflow menu.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHostOptionsDialog(
|
||||||
|
hostName: String,
|
||||||
|
canWake: Boolean,
|
||||||
|
onWake: () -> Unit,
|
||||||
|
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onForget: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = hostName,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = buildList {
|
||||||
|
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||||
|
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||||
|
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||||
|
add(DialogAction("Forget", onClick = onForget))
|
||||||
|
add(DialogAction("Cancel", onClick = onDismiss))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DialogText("Manage this saved host.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Trust this host?",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Pair with PIN", onClick = onPairInstead),
|
||||||
|
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText("First connection to ${pt.host}:${pt.port}.")
|
||||||
|
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") }
|
||||||
|
DialogText(
|
||||||
|
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
|
||||||
|
"Pairing with a PIN is stronger — it proves both sides.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Host identity changed",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText(
|
||||||
|
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
|
||||||
|
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Pairing required",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = listOf(
|
||||||
|
DialogAction("Cancel", onClick = onDismiss),
|
||||||
|
DialogAction("Use a PIN", onClick = onUsePin),
|
||||||
|
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||||
|
DialogText(
|
||||||
|
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
|
||||||
|
"Or pair with the 4-digit PIN the host displays.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||||
|
GamepadDialog(
|
||||||
|
title = "Waiting for approval",
|
||||||
|
onDismiss = onCancel,
|
||||||
|
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||||
|
) {
|
||||||
|
val deviceName = Build.MODEL ?: "this device"
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||||
|
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||||
|
}
|
||||||
|
DialogText(
|
||||||
|
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||||
|
"once you approve — no PIN needed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
|
||||||
|
* 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
|
||||||
|
* fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||||
|
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||||
|
var pairing by remember(pt) { mutableStateOf(false) }
|
||||||
|
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||||
|
val name = remember { Build.MODEL ?: "Android" }
|
||||||
|
|
||||||
|
fun pair() {
|
||||||
|
val id = identity ?: return
|
||||||
|
pairing = true
|
||||||
|
err = null
|
||||||
|
val pin = digits.joinToString("")
|
||||||
|
scope.launch {
|
||||||
|
val fp = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||||
|
}
|
||||||
|
pairing = false
|
||||||
|
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = { if (!pairing) onDismiss() })
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = !pairing,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.LEFT -> if (slot > 0) slot--
|
||||||
|
NavDir.RIGHT -> if (slot < 4) slot++
|
||||||
|
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
|
||||||
|
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { if (slot == 4 && identity != null) pair() },
|
||||||
|
)
|
||||||
|
|
||||||
|
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||||
|
}
|
||||||
|
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||||
|
DialogButton(
|
||||||
|
label = if (pairing) "Pairing…" else "Pair",
|
||||||
|
focused = slot == 4 && !pairing,
|
||||||
|
primary = true,
|
||||||
|
enabled = !pairing && identity != null,
|
||||||
|
onClick = { if (identity != null) pair() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinSlot(value: Int, focused: Boolean) {
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
Box(
|
||||||
|
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||||
|
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
|
||||||
|
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||||
|
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||||
|
|
||||||
|
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||||
|
class HomeTile(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
|
||||||
|
val online: Boolean = false, // advertising on the LAN right now
|
||||||
|
val paired: Boolean = false, // pinned identity (shows a lock)
|
||||||
|
val connecting: Boolean = false,
|
||||||
|
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
|
||||||
|
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||||
|
val activate: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||||
|
// first" message if the host hasn't authorized this device for its management API.
|
||||||
|
val hasLibrary: Boolean get() = knownHost != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
|
||||||
|
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
|
||||||
|
* / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadHome(
|
||||||
|
tiles: List<HomeTile>,
|
||||||
|
libraryEnabled: Boolean,
|
||||||
|
controllerName: String?,
|
||||||
|
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
|
||||||
|
// can be driven instead.
|
||||||
|
navActive: Boolean,
|
||||||
|
onActivate: (HomeTile) -> Unit,
|
||||||
|
onOpenLibrary: (HomeTile) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
|
||||||
|
onOptions: (HomeTile) -> Unit = {},
|
||||||
|
) {
|
||||||
|
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
|
||||||
|
// sits the same distance from the left and the bottom).
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
val pagerState = rememberPagerState(pageCount = { tiles.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
|
||||||
|
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
|
||||||
|
// mid-animation (which is what let a flick overshoot by two).
|
||||||
|
var navTarget by remember { mutableStateOf(0) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
|
val current = tiles.getOrNull(navTarget)
|
||||||
|
|
||||||
|
GamepadNavEffect(
|
||||||
|
active = navActive && tiles.isNotEmpty(),
|
||||||
|
onMove = { dir ->
|
||||||
|
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
|
||||||
|
if (target != navTarget) {
|
||||||
|
navTarget = target
|
||||||
|
scope.launch { pagerState.animateScrollToPage(target) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||||
|
onSecondary = { // Y (gamepad) → Library
|
||||||
|
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||||
|
},
|
||||||
|
onTertiary = onOpenSettings, // X (gamepad) → Settings
|
||||||
|
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
|
||||||
|
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
|
||||||
|
onUp = onOpenSettings,
|
||||||
|
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||||
|
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
|
||||||
|
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
|
||||||
|
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
|
||||||
|
// way. Each hint is also TAPPABLE (touch hatch).
|
||||||
|
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||||
|
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||||
|
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||||
|
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||||
|
val arrowTint = Color(0xFF9A93C7)
|
||||||
|
val hints = buildList {
|
||||||
|
if (padIsGamepad) {
|
||||||
|
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||||
|
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
|
||||||
|
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
|
||||||
|
})
|
||||||
|
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
|
||||||
|
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
|
||||||
|
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
|
||||||
|
} else {
|
||||||
|
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
|
||||||
|
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
|
||||||
|
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
|
||||||
|
// whatever scrolls under it.
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
|
||||||
|
// no longer push the cards below the true centre.
|
||||||
|
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||||
|
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||||
|
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||||
|
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = PageSize.Fixed(cardWidth),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePad),
|
||||||
|
pageSpacing = 22.dp,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) { page ->
|
||||||
|
val tile = tiles[page]
|
||||||
|
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||||
|
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||||
|
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||||
|
.absoluteValue.coerceIn(0f, 1f)
|
||||||
|
GamepadHostTile(
|
||||||
|
tile = tile,
|
||||||
|
modifier = Modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
val s = lerp(1f, 0.86f, offset)
|
||||||
|
scaleX = s
|
||||||
|
scaleY = s
|
||||||
|
alpha = lerp(1f, 0.5f, offset)
|
||||||
|
}
|
||||||
|
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||||
|
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||||
|
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||||
|
.height(cardHeight)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {
|
||||||
|
if (page == navTarget) {
|
||||||
|
onActivate(tile)
|
||||||
|
} else {
|
||||||
|
navTarget = page
|
||||||
|
scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||||
|
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||||
|
Row(
|
||||||
|
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||||
|
.padding(end = ConsoleEdgeInset),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
|
||||||
|
if (controllerName != null) ControllerStatusChip(controllerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||||
|
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(hints, hazeState = hazeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||||
|
@Composable
|
||||||
|
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||||
|
val shape = RoundedCornerShape(26.dp)
|
||||||
|
val wash = if (tile.filled) {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||||
|
} else {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(wash)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||||
|
.padding(22.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
|
MonogramBadge(tile)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (tile.paired) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
contentDescription = "Paired",
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tile.online) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
|
||||||
|
.background(Color(0xFF3CD070)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
tile.title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
tile.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonogramBadge(tile: HomeTile) {
|
||||||
|
val shape = RoundedCornerShape(15.dp)
|
||||||
|
val fill = if (tile.filled) {
|
||||||
|
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||||
|
} else {
|
||||||
|
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
tile.connecting -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
tile.isAdd -> Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||||
|
)
|
||||||
|
else -> Text(
|
||||||
|
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
// Controller navigation for the console carousels (host launcher + library coverflow). It taps the
|
||||||
|
// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so
|
||||||
|
// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis —
|
||||||
|
// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a
|
||||||
|
// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left
|
||||||
|
// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it
|
||||||
|
// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces
|
||||||
|
// the moves against a target index so a fast repeat walks smoothly instead of overshooting.
|
||||||
|
|
||||||
|
private const val STICK_HIGH = 0.6f // cross this to commit a move
|
||||||
|
private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis)
|
||||||
|
private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat
|
||||||
|
private const val REPEAT_MS = 150L // then repeat this often while held
|
||||||
|
|
||||||
|
private class NavInputState {
|
||||||
|
@Volatile var stickX = 0f
|
||||||
|
@Volatile var stickY = 0f
|
||||||
|
@Volatile var hatX = 0f
|
||||||
|
@Volatile var hatY = 0f
|
||||||
|
@Volatile var dpadX = 0
|
||||||
|
@Volatile var dpadY = 0
|
||||||
|
fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A committed navigation direction from the stick / D-pad / HAT. */
|
||||||
|
enum class NavDir { UP, DOWN, LEFT, RIGHT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1
|
||||||
|
* (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X,
|
||||||
|
* [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the
|
||||||
|
* screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops
|
||||||
|
* consuming the pad and the overlay can be navigated.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadNavEffect(
|
||||||
|
active: Boolean,
|
||||||
|
onMove: (Int) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
|
onSecondary: () -> Unit = {},
|
||||||
|
onTertiary: () -> Unit = {},
|
||||||
|
// D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button.
|
||||||
|
onUp: () -> Unit = {},
|
||||||
|
onDown: () -> Unit = {},
|
||||||
|
// Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK
|
||||||
|
// button (the Android-TV context-menu convention). A short OK press is [onActivate].
|
||||||
|
onOptions: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current as? MainActivity ?: return
|
||||||
|
val state = remember { NavInputState() }
|
||||||
|
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
|
||||||
|
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
|
||||||
|
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
|
||||||
|
// long-lived coroutine/probes pointed at the CURRENT callbacks.
|
||||||
|
val currentOnMove by rememberUpdatedState(onMove)
|
||||||
|
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||||
|
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||||
|
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||||
|
val currentOnUp by rememberUpdatedState(onUp)
|
||||||
|
val currentOnDown by rememberUpdatedState(onDown)
|
||||||
|
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||||
|
|
||||||
|
DisposableEffect(active) {
|
||||||
|
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||||
|
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||||
|
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||||
|
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||||
|
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||||
|
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||||
|
val edge = down && ev.repeatCount == 0
|
||||||
|
when (ev.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||||
|
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||||
|
// The gamepad Select / View / Share button → context options (a remote uses Down).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||||
|
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
activity.padMotionProbe = motionProbe
|
||||||
|
activity.padKeyProbe = keyProbe
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||||
|
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||||
|
state.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
var committed = 0 // the direction currently held (hysteresis + repeat authority)
|
||||||
|
var fireAt = 0L // uptime at/after which the next auto-repeat may fire
|
||||||
|
while (isActive) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0
|
||||||
|
val dir = when {
|
||||||
|
state.dpadX != 0 -> state.dpadX
|
||||||
|
hat != 0 -> hat
|
||||||
|
else -> {
|
||||||
|
val x = state.stickX
|
||||||
|
when {
|
||||||
|
x >= STICK_HIGH -> 1
|
||||||
|
x <= -STICK_HIGH -> -1
|
||||||
|
abs(x) < STICK_LOW -> 0
|
||||||
|
else -> committed // inside the hysteresis band → hold the committed value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
dir == 0 -> committed = 0
|
||||||
|
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||||
|
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||||
|
}
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen
|
||||||
|
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||||
|
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||||
|
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||||
|
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||||
|
* one layer": close the keyboard, then the screen).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GamepadNavEffect2D(
|
||||||
|
active: Boolean,
|
||||||
|
onDirection: (NavDir) -> Unit,
|
||||||
|
onActivate: () -> Unit,
|
||||||
|
onTertiary: () -> Unit = {},
|
||||||
|
onSecondary: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current as? MainActivity ?: return
|
||||||
|
val state = remember { NavInputState() }
|
||||||
|
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||||
|
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||||
|
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||||
|
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||||
|
|
||||||
|
DisposableEffect(active) {
|
||||||
|
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||||
|
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||||
|
// teardown must not null out the incoming screen's just-installed probes.
|
||||||
|
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||||
|
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||||
|
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||||
|
state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||||
|
state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||||
|
return@probe true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||||
|
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||||
|
val edge = down && ev.repeatCount == 0
|
||||||
|
when (ev.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||||
|
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
activity.padMotionProbe = motionProbe
|
||||||
|
activity.padKeyProbe = keyProbe
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||||
|
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||||
|
state.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
var committed: NavDir? = null
|
||||||
|
var fireAt = 0L
|
||||||
|
while (isActive) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val raw = resolveDir(state)
|
||||||
|
val nearCentre = state.dpadX == 0 && state.dpadY == 0 &&
|
||||||
|
abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f &&
|
||||||
|
abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW
|
||||||
|
when {
|
||||||
|
raw == null && nearCentre -> committed = null
|
||||||
|
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||||
|
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||||
|
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||||
|
}
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */
|
||||||
|
private fun resolveDir(s: NavInputState): NavDir? {
|
||||||
|
if (s.dpadY < 0) return NavDir.UP
|
||||||
|
if (s.dpadY > 0) return NavDir.DOWN
|
||||||
|
if (s.dpadX < 0) return NavDir.LEFT
|
||||||
|
if (s.dpadX > 0) return NavDir.RIGHT
|
||||||
|
if (s.hatY <= -0.5f) return NavDir.UP
|
||||||
|
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||||
|
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||||
|
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||||
|
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||||
|
when {
|
||||||
|
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||||
|
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
s.stickX <= -STICK_HIGH -> NavDir.LEFT
|
||||||
|
s.stickX >= STICK_HIGH -> NavDir.RIGHT
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
|
||||||
|
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||||
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
|
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||||
|
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||||
|
|
||||||
|
private class GpRow(
|
||||||
|
val id: String,
|
||||||
|
val header: String?,
|
||||||
|
val label: String,
|
||||||
|
val value: String,
|
||||||
|
val detail: String,
|
||||||
|
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||||
|
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadSettingsScreen(
|
||||||
|
initial: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
|
||||||
|
) {
|
||||||
|
var s by remember { mutableStateOf(initial) }
|
||||||
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
|
val rows = buildSettingsRows(s, ::update)
|
||||||
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = navActive,
|
||||||
|
onDirection = { dir ->
|
||||||
|
when (dir) {
|
||||||
|
NavDir.UP -> if (focus > 0) focus--
|
||||||
|
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||||
|
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
|
||||||
|
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActivate = { rows.getOrNull(focus)?.activate() },
|
||||||
|
)
|
||||||
|
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||||
|
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||||
|
// +1 accounts for the heading being item 0.
|
||||||
|
LaunchedEffect(focus) {
|
||||||
|
runCatching {
|
||||||
|
val itemIndex = focus + 1
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||||
|
val offScreen = item == null ||
|
||||||
|
item.offset < info.viewportStartOffset ||
|
||||||
|
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
|
||||||
|
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||||
|
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadFormBackground(Modifier.fillMaxSize())
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||||
|
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
item(key = "__title") {
|
||||||
|
ConsoleHeader("Settings", horizontalInset = false)
|
||||||
|
}
|
||||||
|
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||||
|
SettingRowView(row, focused = index == focus, onClick = {
|
||||||
|
if (focus == index) row.activate() else focus = index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
|
||||||
|
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(
|
||||||
|
listOf(
|
||||||
|
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||||
|
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||||
|
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||||
|
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||||
|
),
|
||||||
|
hazeState = hazeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
|
||||||
|
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
Column {
|
||||||
|
if (row.header != null) {
|
||||||
|
Text(
|
||||||
|
row.header.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.45f),
|
||||||
|
letterSpacing = 1.4.sp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
row.label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f))
|
||||||
|
Text(
|
||||||
|
row.value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
|
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||||
|
// detail strip. It appears right where you're looking, and the row grows to fit.
|
||||||
|
if (focused && row.detail.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
row.detail,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||||
|
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||||
|
fun <T> choice(
|
||||||
|
id: String, header: String?, label: String, detail: String,
|
||||||
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
|
): GpRow {
|
||||||
|
val idx = options.indexOfFirst { it.first == current }
|
||||||
|
return GpRow(
|
||||||
|
id, header, label,
|
||||||
|
value = options.getOrNull(idx)?.second ?: "—",
|
||||||
|
detail = detail,
|
||||||
|
adjust = { delta ->
|
||||||
|
if (idx < 0) {
|
||||||
|
options.firstOrNull()?.let { write(it.first) } != null
|
||||||
|
} else {
|
||||||
|
val t = idx + delta
|
||||||
|
if (t in options.indices) { write(options[t].first); true } else false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activate = {
|
||||||
|
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||||
|
options.getOrNull(i)?.let { write(it.first) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun toggle(
|
||||||
|
id: String, header: String?, label: String, detail: String,
|
||||||
|
value: Boolean, write: (Boolean) -> Unit,
|
||||||
|
): GpRow = GpRow(
|
||||||
|
id, header, label,
|
||||||
|
value = if (value) "On" else "Off",
|
||||||
|
detail = detail,
|
||||||
|
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||||
|
activate = { write(!value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
choice(
|
||||||
|
"resolution", "Stream", "Resolution",
|
||||||
|
"The host creates a virtual display at exactly this size — no scaling.",
|
||||||
|
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
|
||||||
|
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||||
|
choice(
|
||||||
|
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||||
|
REFRESH_OPTIONS, s.hz,
|
||||||
|
) { update(s.copy(hz = it)) },
|
||||||
|
choice(
|
||||||
|
"bitrate", null, "Bitrate",
|
||||||
|
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||||
|
BITRATE_OPTIONS, s.bitrateKbps,
|
||||||
|
) { update(s.copy(bitrateKbps = it)) },
|
||||||
|
choice(
|
||||||
|
"compositor", null, "Compositor",
|
||||||
|
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||||
|
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||||
|
) { update(s.copy(compositor = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"codec", "Video", "Video codec",
|
||||||
|
"A preference — the host falls back if it can't encode this one.",
|
||||||
|
CODEC_OPTIONS, s.codec,
|
||||||
|
) { update(s.copy(codec = it)) },
|
||||||
|
toggle(
|
||||||
|
"hdr", null, "10-bit HDR",
|
||||||
|
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||||
|
s.hdrEnabled,
|
||||||
|
) { update(s.copy(hdrEnabled = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||||
|
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||||
|
) { update(s.copy(audioChannels = it)) },
|
||||||
|
toggle(
|
||||||
|
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||||
|
s.micEnabled,
|
||||||
|
) { update(s.copy(micEnabled = it)) },
|
||||||
|
|
||||||
|
choice(
|
||||||
|
"padType", "Controller", "Controller type",
|
||||||
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
|
||||||
|
toggle(
|
||||||
|
"hud", "Interface", "Statistics overlay",
|
||||||
|
"Show FPS, throughput and latency while streaming.",
|
||||||
|
s.statsHudEnabled,
|
||||||
|
) { update(s.copy(statsHudEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"library", null, "Game library",
|
||||||
|
"Browse a paired host's games with Y (experimental).",
|
||||||
|
s.libraryEnabled,
|
||||||
|
) { update(s.copy(libraryEnabled = it)) },
|
||||||
|
toggle(
|
||||||
|
"gamepadUI", null, "Controller-optimized UI",
|
||||||
|
"Turn off to use the touch interface even with a controller connected.",
|
||||||
|
s.gamepadUiEnabled,
|
||||||
|
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||||
|
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||||
|
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||||
|
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||||
|
* console UI (as long as the setting is on).
|
||||||
|
*/
|
||||||
|
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||||
|
enabled && (controllerConnected || tv || forced)
|
||||||
|
|
||||||
|
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||||
|
fun isTvDevice(context: Context): Boolean {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||||
|
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
|
||||||
|
return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live "is a game controller attached" state, updated as pads connect/disconnect via
|
||||||
|
* [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is
|
||||||
|
* plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client
|
||||||
|
* gets from observing `GamepadManager.shared`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberControllerConnected(): State<Boolean> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
val listener = object : InputManager.InputDeviceListener {
|
||||||
|
private fun refresh() { connected.value = Gamepad.firstPad() != null }
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) = refresh()
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) = refresh()
|
||||||
|
}
|
||||||
|
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||||
|
connected.value = Gamepad.firstPad() != null
|
||||||
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
|
}
|
||||||
|
return connected
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||||
|
import io.unom.punktfunk.kit.library.GameEntry
|
||||||
|
import io.unom.punktfunk.kit.library.LibraryClient
|
||||||
|
import io.unom.punktfunk.kit.library.LibraryResult
|
||||||
|
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||||
|
import io.unom.punktfunk.kit.security.IdentityStore
|
||||||
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sign
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView:
|
||||||
|
// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D
|
||||||
|
// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host.
|
||||||
|
|
||||||
|
private sealed class LibState {
|
||||||
|
object Loading : LibState()
|
||||||
|
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
|
||||||
|
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||||
|
|
||||||
|
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||||
|
state = LibState.Loading
|
||||||
|
state = withContext(Dispatchers.IO) {
|
||||||
|
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||||
|
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||||
|
when (val res = LibraryClient.fetch(
|
||||||
|
address = host.address,
|
||||||
|
mgmtPort = DEFAULT_MGMT_PORT,
|
||||||
|
certPem = id.certPem,
|
||||||
|
keyPem = id.privateKeyPem,
|
||||||
|
fpHex = host.fpHex,
|
||||||
|
)) {
|
||||||
|
is LibraryResult.Ok -> if (res.games.isEmpty()) {
|
||||||
|
LibState.Message("No games found on this host.")
|
||||||
|
} else {
|
||||||
|
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||||
|
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
|
||||||
|
}
|
||||||
|
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||||
|
is LibraryResult.Error -> LibState.Message(res.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
|
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
ConsoleHeader("${host.name} — Library")
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
when (val s = state) {
|
||||||
|
is LibState.Loading -> LoadingState()
|
||||||
|
is LibState.Message -> MessageState(s.text)
|
||||||
|
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
||||||
|
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||||
|
Box(
|
||||||
|
Modifier.align(Alignment.BottomStart)
|
||||||
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
|
.padding(ConsoleLegendInset),
|
||||||
|
) {
|
||||||
|
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingState() {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageState(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||||
|
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||||
|
val coverWidth = coverHeight * 2f / 3f
|
||||||
|
val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp)
|
||||||
|
val pagerState = rememberPagerState(pageCount = { games.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var navTarget by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
|
val current = games.getOrNull(navTarget)
|
||||||
|
|
||||||
|
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
||||||
|
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
||||||
|
// via the screen's BackHandler.
|
||||||
|
GamepadNavEffect(
|
||||||
|
active = navActive && games.isNotEmpty(),
|
||||||
|
onMove = { dir ->
|
||||||
|
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||||
|
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||||
|
},
|
||||||
|
onActivate = { /* launch a title — browse-only for now */ },
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = PageSize.Fixed(coverWidth),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePad),
|
||||||
|
pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer
|
||||||
|
beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible
|
||||||
|
modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) { page ->
|
||||||
|
val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||||
|
val d = signed.absoluteValue
|
||||||
|
Poster(
|
||||||
|
game = games[page],
|
||||||
|
loader = loader,
|
||||||
|
modifier = Modifier
|
||||||
|
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||||
|
.width(coverWidth)
|
||||||
|
.height(coverHeight)
|
||||||
|
.graphicsLayer {
|
||||||
|
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||||
|
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||||
|
// the "edges spread apart while the centre gets crowded" look.)
|
||||||
|
val scale = 1f - 0.28f * d.coerceAtMost(1f)
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size
|
||||||
|
val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward
|
||||||
|
rotationY = rotDeg
|
||||||
|
// Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over
|
||||||
|
// the first step so scrolling stays smooth) so the CENTRE card breathes.
|
||||||
|
val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f
|
||||||
|
// Counter-balance: a rotated card projects narrower (≈cos θ), which opens its
|
||||||
|
// inner gap — pull it back toward centre by the half-width it loses so the
|
||||||
|
// gaps stay even no matter the tilt.
|
||||||
|
val halfW = size.width * scale * 0.5f
|
||||||
|
val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f)))
|
||||||
|
translationX = base + counter
|
||||||
|
// Lower cameraDistance = stronger perspective (CSS `perspective`); the flat
|
||||||
|
// 22 washed the tilt out. 9 makes the same angle read as real depth.
|
||||||
|
cameraDistance = 9f * density
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0.5f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
current?.title ?: " ",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (current != null) {
|
||||||
|
Text(
|
||||||
|
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||||
|
@Composable
|
||||||
|
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||||
|
val candidates = game.art.posterCandidates
|
||||||
|
var idx by remember(game.id) { mutableStateOf(0) }
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(Color(0xFF241F3D))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (idx < candidates.size) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(),
|
||||||
|
imageLoader = loader,
|
||||||
|
contentDescription = game.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
game.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Store badge, top-start.
|
||||||
|
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||||
|
Text(
|
||||||
|
if (game.isCustom) "Custom" else "Steam",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,21 @@ package io.unom.punktfunk
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -31,6 +38,13 @@ fun LicensesScreen(onBack: () -> Unit) {
|
|||||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||||
}.getOrDefault("Third-party notices unavailable.")
|
}.getOrDefault("Third-party notices unavailable.")
|
||||||
}
|
}
|
||||||
|
// The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL
|
||||||
|
// requires the license travel with the font, so surface it here (mirrors the Apple client).
|
||||||
|
val fontLicense = remember {
|
||||||
|
runCatching {
|
||||||
|
context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
val version = remember {
|
val version = remember {
|
||||||
runCatching {
|
runCatching {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -38,29 +52,52 @@ fun LicensesScreen(onBack: () -> Unit) {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||||
.fillMaxSize()
|
Row(
|
||||||
.verticalScroll(rememberScrollState())
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
) {
|
||||||
) {
|
IconButton(onClick = onBack) {
|
||||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
if (version != null) {
|
}
|
||||||
Text(
|
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||||
"punktfunk $version",
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Column(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier
|
||||||
)
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
if (version != null) {
|
||||||
|
Text(
|
||||||
|
"Punktfunk $version",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||||
|
"components below, each under its own license.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
notices,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
if (fontLicense != null) {
|
||||||
|
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
fontLicense,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
|
||||||
"components below, each under its own license.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
notices,
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
@@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||||
|
* couch user with no keyboard/Back can always leave a stream.
|
||||||
|
*/
|
||||||
|
var requestStreamExit: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||||
|
private var heldPadButtons = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||||
|
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||||
|
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
||||||
|
*/
|
||||||
|
var lastPadIsGamepad by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
resolveHighRefreshMode()
|
||||||
|
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||||
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
||||||
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
||||||
@@ -43,13 +69,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
)
|
)
|
||||||
|
// Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console
|
||||||
|
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||||
|
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||||
|
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||||
setContent {
|
setContent {
|
||||||
PunktfunkTheme {
|
PunktfunkTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||||
|
private fun resolveHighRefreshMode() {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||||
|
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||||
|
compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }),
|
||||||
|
)?.modeId ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||||
|
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||||
|
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||||
|
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||||
|
*/
|
||||||
|
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||||
|
if (highRefreshModeId == 0) return
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
preferredDisplayModeId = if (high) highRefreshModeId else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
val handle = streamHandle
|
val handle = streamHandle
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
@@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||||
KeyEvent.ACTION_DOWN ->
|
KeyEvent.ACTION_DOWN -> {
|
||||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||||
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
heldPadButtons = heldPadButtons or bit
|
||||||
|
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||||
|
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||||
|
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||||
|
heldPadButtons = 0
|
||||||
|
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyEvent.ACTION_UP -> {
|
||||||
|
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||||
|
heldPadButtons = heldPadButtons and bit.inv()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
@@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||||
|
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||||
|
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||||
|
}
|
||||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||||
padKeyProbe?.let { if (it(event)) return true }
|
padKeyProbe?.let { if (it(event)) return true }
|
||||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already
|
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||||
// move focus on their own, so they fall through to super untouched.
|
// already move focus on their own, so they fall through to super untouched.
|
||||||
val mapped = when (event.keyCode) {
|
when (event.keyCode) {
|
||||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||||
else -> 0
|
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||||
|
if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A → activate the focused element (the focus system understands DPAD_CENTER).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A ->
|
||||||
|
return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||||
}
|
}
|
||||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return super.dispatchKeyEvent(event)
|
return super.dispatchKeyEvent(event)
|
||||||
@@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (dir != lastNavDir) {
|
if (dir != lastNavDir) {
|
||||||
lastNavDir = dir
|
lastNavDir = dir
|
||||||
if (dir != 0) {
|
if (dir != 0) {
|
||||||
|
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||||
return true
|
return true
|
||||||
@@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||||
|
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
|
-> true
|
||||||
|
else -> KeyEvent.isGamepadButton(kc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||||
|
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ data class Settings(
|
|||||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||||
*/
|
*/
|
||||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||||
|
/**
|
||||||
|
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||||
|
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||||
|
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||||
|
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||||
|
*/
|
||||||
|
val gamepadUiEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||||
|
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||||
|
* client's `libraryEnabled`.
|
||||||
|
*/
|
||||||
|
val libraryEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -67,6 +80,8 @@ class SettingsStore(context: Context) {
|
|||||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -83,6 +98,8 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +116,8 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_MIC = "mic_enabled"
|
const val K_MIC = "mic_enabled"
|
||||||
const val K_HUD = "stats_hud_enabled"
|
const val K_HUD = "stats_hud_enabled"
|
||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -5,44 +5,79 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.material.icons.filled.VolumeUp
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||||
* resolve from the device display at connect time.
|
* 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.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
initial: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showLicenses by remember { mutableStateOf(false) }
|
var showLicenses by remember { mutableStateOf(false) }
|
||||||
@@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
onChange(next)
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||||
val micLauncher = rememberLauncherForActivityResult(
|
val micLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||||
|
val onMicChange: (Boolean) -> Unit = { on ->
|
||||||
|
when {
|
||||||
|
!on -> update(s.copy(micEnabled = false))
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||||
|
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep sub-screens replace the whole settings surface (they carry their own back).
|
||||||
if (showLicenses) {
|
if (showLicenses) {
|
||||||
LicensesScreen(onBack = { showLicenses = false })
|
LicensesScreen(onBack = { showLicenses = false })
|
||||||
return
|
return
|
||||||
@@ -68,160 +110,314 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
|
||||||
modifier = Modifier
|
var selectedName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
.fillMaxSize()
|
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
) {
|
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
|
|
||||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
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.Display.name
|
||||||
|
}
|
||||||
|
|
||||||
SettingsGroup("Display") {
|
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||||
SettingDropdown(
|
CategoryDetail(
|
||||||
label = "Resolution",
|
category = cat,
|
||||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
settings = s,
|
||||||
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
|
onChange = ::update,
|
||||||
},
|
context = context,
|
||||||
selected = s.width to s.height,
|
onMicChange = onMicChange,
|
||||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
onOpenControllers = { showControllers = true },
|
||||||
|
onOpenLicenses = { showLicenses = true },
|
||||||
SettingDropdown(
|
onBack = back,
|
||||||
label = "Refresh rate",
|
|
||||||
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
|
||||||
selected = s.hz,
|
|
||||||
) { hz -> update(s.copy(hz = hz)) }
|
|
||||||
|
|
||||||
SettingDropdown(
|
|
||||||
label = "Bitrate",
|
|
||||||
options = BITRATE_OPTIONS,
|
|
||||||
selected = s.bitrateKbps,
|
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
|
||||||
|
|
||||||
SettingDropdown(
|
|
||||||
label = "Video codec",
|
|
||||||
options = CODEC_OPTIONS,
|
|
||||||
selected = s.codec,
|
|
||||||
) { c -> update(s.copy(codec = c)) }
|
|
||||||
|
|
||||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
|
||||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
|
||||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
|
||||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
|
||||||
ToggleRow(
|
|
||||||
title = "HDR",
|
|
||||||
subtitle = if (hdrCapable) {
|
|
||||||
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
|
||||||
} else {
|
|
||||||
"This display can't present HDR10 — streams stay SDR"
|
|
||||||
},
|
|
||||||
checked = s.hdrEnabled && hdrCapable,
|
|
||||||
enabled = hdrCapable,
|
|
||||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsGroup("Host") {
|
if (twoPane) {
|
||||||
SettingDropdown(
|
BackHandler(onBack = onBack)
|
||||||
label = "Compositor",
|
Row(Modifier.fillMaxSize()) {
|
||||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
CategoryList(
|
||||||
selected = s.compositor,
|
selected = selected,
|
||||||
) { c -> update(s.copy(compositor = c)) }
|
twoPane = true,
|
||||||
|
onSelect = { selectedName = it.name },
|
||||||
SettingDropdown(
|
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||||
label = "Controller type",
|
)
|
||||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
VerticalDivider()
|
||||||
selected = s.gamepad,
|
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
) { g -> update(s.copy(gamepad = g)) }
|
// Cross-fade the detail pane as the selected category changes.
|
||||||
|
AnimatedContent(
|
||||||
ClickableRow(
|
targetState = selected ?: SettingsCategory.Display,
|
||||||
title = "Connected controllers",
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
subtitle = "What the app detects, with a live input test",
|
label = "SettingsPane",
|
||||||
onClick = { showControllers = true },
|
) { cat -> detail(cat, null) }
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
SettingsGroup("Audio") {
|
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
|
||||||
SettingDropdown(
|
// Android system settings — a horizontal slide that tracks the drill-in direction.
|
||||||
label = "Audio channels",
|
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||||
options = AUDIO_CHANNEL_OPTIONS,
|
AnimatedContent(
|
||||||
selected = s.audioChannels,
|
targetState = selected,
|
||||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
transitionSpec = {
|
||||||
|
if (targetState != null) {
|
||||||
ToggleRow(
|
slideInHorizontally { it } + fadeIn() togetherWith
|
||||||
title = "Microphone",
|
slideOutHorizontally { -it } + fadeOut()
|
||||||
subtitle = "Send your mic to the host's virtual microphone",
|
} else {
|
||||||
checked = s.micEnabled,
|
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||||
onCheckedChange = { on ->
|
slideOutHorizontally { it } + fadeOut()
|
||||||
when {
|
|
||||||
!on -> update(s.copy(micEnabled = false))
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
|
||||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
label = "SettingsPush",
|
||||||
}
|
) { sel ->
|
||||||
|
if (sel == null) {
|
||||||
SettingsGroup("Touch input") {
|
CategoryList(
|
||||||
SettingDropdown(
|
selected = null,
|
||||||
label = "Touch input",
|
twoPane = false,
|
||||||
options = TOUCH_MODE_OPTIONS,
|
onSelect = { selectedName = it.name },
|
||||||
selected = s.touchMode,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onSelect = { mode -> update(s.copy(touchMode = mode)) },
|
)
|
||||||
)
|
} else {
|
||||||
Text(
|
detail(sel) { selectedName = null }
|
||||||
"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: real " +
|
|
||||||
"multi-touch reaches the host, for apps that understand touch.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsGroup("Overlay") {
|
|
||||||
ToggleRow(
|
|
||||||
title = "Stats overlay",
|
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
|
||||||
checked = s.statsHudEnabled,
|
|
||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsGroup("About") {
|
|
||||||
ClickableRow(
|
|
||||||
title = "Open-source licenses",
|
|
||||||
subtitle = "Third-party notices and credits",
|
|
||||||
onClick = { showLicenses = true },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A titled group of settings rendered inside an outlined card. */
|
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */
|
||||||
|
enum class SettingsCategory(val title: String, val icon: ImageVector) {
|
||||||
|
Display("Display", Icons.Filled.Tv),
|
||||||
|
Audio("Audio", Icons.Filled.VolumeUp),
|
||||||
|
Controls("Controls", Icons.Filled.SportsEsports),
|
||||||
|
Interface("Interface", Icons.Filled.Tune),
|
||||||
|
About("About", Icons.Filled.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
|
private fun CategoryList(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
selected: SettingsCategory?,
|
||||||
|
twoPane: Boolean,
|
||||||
|
onSelect: (SettingsCategory) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 12.dp, vertical = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
"Settings",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||||
modifier = Modifier.padding(start = 4.dp),
|
|
||||||
)
|
)
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
SettingsCategory.entries.forEach { cat ->
|
||||||
Column(
|
val highlighted = twoPane && selected == cat
|
||||||
modifier = Modifier.padding(16.dp),
|
Row(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
modifier = Modifier
|
||||||
content = content,
|
.fillMaxWidth()
|
||||||
)
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
|
||||||
|
.clickable { onSelect(cat) }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 15.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
cat.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
)
|
||||||
|
Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
|
if (!twoPane) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */
|
||||||
|
@Composable
|
||||||
|
private fun CategoryDetail(
|
||||||
|
category: SettingsCategory,
|
||||||
|
settings: Settings,
|
||||||
|
onChange: (Settings) -> Unit,
|
||||||
|
context: android.content.Context,
|
||||||
|
onMicChange: (Boolean) -> Unit,
|
||||||
|
onOpenControllers: () -> Unit,
|
||||||
|
onOpenLicenses: () -> Unit,
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (onBack != null) {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(category.title, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
}
|
||||||
|
when (category) {
|
||||||
|
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
|
||||||
|
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
|
||||||
|
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers)
|
||||||
|
SettingsCategory.Interface -> InterfaceSettings(settings, onChange)
|
||||||
|
SettingsCategory.About -> AboutSettings(onOpenLicenses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||||
|
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Resolution",
|
||||||
|
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
|
||||||
|
selected = s.width to s.height,
|
||||||
|
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Refresh rate",
|
||||||
|
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
|
||||||
|
selected = s.hz,
|
||||||
|
) { hz -> update(s.copy(hz = hz)) }
|
||||||
|
|
||||||
|
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
|
||||||
|
update(s.copy(bitrateKbps = kbps))
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
|
||||||
|
update(s.copy(codec = c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
|
||||||
|
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
|
||||||
|
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||||
|
ToggleRow(
|
||||||
|
title = "HDR",
|
||||||
|
subtitle = if (hdrCapable) {
|
||||||
|
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||||
|
} else {
|
||||||
|
"This display can't present HDR10 — streams stay SDR"
|
||||||
|
},
|
||||||
|
checked = s.hdrEnabled && hdrCapable,
|
||||||
|
enabled = hdrCapable,
|
||||||
|
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Compositor",
|
||||||
|
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
|
selected = s.compositor,
|
||||||
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch ->
|
||||||
|
update(s.copy(audioChannels = ch))
|
||||||
|
}
|
||||||
|
ToggleRow(
|
||||||
|
title = "Microphone",
|
||||||
|
subtitle = "Send your mic to the host's virtual microphone",
|
||||||
|
checked = s.micEnabled,
|
||||||
|
onCheckedChange = onMicChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
|
||||||
|
update(s.copy(touchMode = mode))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"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: real multi-touch reaches the " +
|
||||||
|
"host, for apps that understand touch.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsCard {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Controller type",
|
||||||
|
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||||
|
selected = s.gamepad,
|
||||||
|
) { g -> update(s.copy(gamepad = g)) }
|
||||||
|
ClickableRow(
|
||||||
|
title = "Connected controllers",
|
||||||
|
subtitle = "What the app detects, with a live input test",
|
||||||
|
onClick = onOpenControllers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
ToggleRow(
|
||||||
|
title = "Controller-optimized UI",
|
||||||
|
subtitle = "Switch to the console home (host carousel) when a controller is connected",
|
||||||
|
checked = s.gamepadUiEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Game library",
|
||||||
|
subtitle = "Browse a paired host's game library (press Y on a saved host)",
|
||||||
|
checked = s.libraryEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Stats overlay",
|
||||||
|
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||||
|
checked = s.statsHudEnabled,
|
||||||
|
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutSettings(onOpenLicenses: () -> Unit) {
|
||||||
|
SettingsCard {
|
||||||
|
ClickableRow(
|
||||||
|
title = "Open-source licenses",
|
||||||
|
subtitle = "Third-party notices and credits",
|
||||||
|
onClick = onOpenLicenses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of settings rendered inside an outlined card. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleRow(
|
private fun ToggleRow(
|
||||||
@@ -265,6 +461,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||||
|
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
|
||||||
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -99,6 +101,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||||
activity?.axisMapper = null
|
activity?.axisMapper = null
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
|
activity?.requestStreamExit = null
|
||||||
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||||
|
|||||||
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
|
|||||||
} else {
|
} else {
|
||||||
BrandDark
|
BrandDark
|
||||||
}
|
}
|
||||||
MaterialTheme(colorScheme = scheme, content = content)
|
// Geist Sans across the whole type scale — the brand typeface the website and the Apple client
|
||||||
|
// already ship (see Type.kt).
|
||||||
|
MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship).
|
||||||
|
// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the
|
||||||
|
// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono
|
||||||
|
// is intentionally not shipped (the licenses screen's technical block uses the platform monospace).
|
||||||
|
//
|
||||||
|
// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt).
|
||||||
|
val Geist = FontFamily(
|
||||||
|
Font(R.font.geist_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.geist_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.geist_semibold, FontWeight.SemiBold),
|
||||||
|
Font(R.font.geist_bold, FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no
|
||||||
|
* `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the
|
||||||
|
* Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights.
|
||||||
|
*/
|
||||||
|
val PunktfunkTypography: Typography = Typography().run {
|
||||||
|
Typography(
|
||||||
|
displayLarge = displayLarge.copy(fontFamily = Geist),
|
||||||
|
displayMedium = displayMedium.copy(fontFamily = Geist),
|
||||||
|
displaySmall = displaySmall.copy(fontFamily = Geist),
|
||||||
|
headlineLarge = headlineLarge.copy(fontFamily = Geist),
|
||||||
|
headlineMedium = headlineMedium.copy(fontFamily = Geist),
|
||||||
|
headlineSmall = headlineSmall.copy(fontFamily = Geist),
|
||||||
|
titleLarge = titleLarge.copy(fontFamily = Geist),
|
||||||
|
titleMedium = titleMedium.copy(fontFamily = Geist),
|
||||||
|
titleSmall = titleSmall.copy(fontFamily = Geist),
|
||||||
|
bodyLarge = bodyLarge.copy(fontFamily = Geist),
|
||||||
|
bodyMedium = bodyMedium.copy(fontFamily = Geist),
|
||||||
|
bodySmall = bodySmall.copy(fontFamily = Geist),
|
||||||
|
labelLarge = labelLarge.copy(fontFamily = Geist),
|
||||||
|
labelMedium = labelMedium.copy(fontFamily = Geist),
|
||||||
|
labelSmall = labelSmall.copy(fontFamily = Geist),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the
|
||||||
|
* Apple client's `HostWaker`.
|
||||||
|
*
|
||||||
|
* A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||||
|
* advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one
|
||||||
|
* packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible
|
||||||
|
* "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via
|
||||||
|
* [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for
|
||||||
|
* a wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||||
|
*
|
||||||
|
* [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the
|
||||||
|
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||||
|
*/
|
||||||
|
class WakeController(private val scope: CoroutineScope) {
|
||||||
|
/** null = idle; non-null drives [WakeOverlay]. */
|
||||||
|
data class Waking(
|
||||||
|
val hostName: String,
|
||||||
|
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||||
|
val connectsAfter: Boolean,
|
||||||
|
val seconds: Int = 0,
|
||||||
|
val timedOut: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
var waking by mutableStateOf<Waking?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
/** Captured so "Try Again" replays the exact same wait. */
|
||||||
|
private var replay: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target
|
||||||
|
* the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host
|
||||||
|
* is already up (a race between the caller's check and here).
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
hostName: String,
|
||||||
|
connectsAfter: Boolean,
|
||||||
|
macs: List<String>,
|
||||||
|
lastIp: String,
|
||||||
|
isOnline: () -> Boolean,
|
||||||
|
onOnline: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (macs.isEmpty() || isOnline()) {
|
||||||
|
cancel()
|
||||||
|
onOnline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) }
|
||||||
|
replay?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop waiting and dismiss the overlay (B / Cancel). */
|
||||||
|
fun cancel() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
replay = null
|
||||||
|
waking = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restart the wait after a timeout (A / Try Again). */
|
||||||
|
fun retry() {
|
||||||
|
replay?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(
|
||||||
|
hostName: String,
|
||||||
|
connectsAfter: Boolean,
|
||||||
|
macs: List<String>,
|
||||||
|
lastIp: String,
|
||||||
|
isOnline: () -> Boolean,
|
||||||
|
onOnline: () -> Unit,
|
||||||
|
) {
|
||||||
|
loop?.cancel()
|
||||||
|
waking = Waking(hostName = hostName, connectsAfter = connectsAfter)
|
||||||
|
loop = scope.launch {
|
||||||
|
var elapsed = 0
|
||||||
|
while (isActive) {
|
||||||
|
// Re-send periodically: a single packet can be missed, and some NICs only wake on a
|
||||||
|
// fresh packet after dropping into a deeper sleep state.
|
||||||
|
if (elapsed % RESEND_EVERY_S == 0) {
|
||||||
|
val csv = macs.joinToString(",")
|
||||||
|
launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) }
|
||||||
|
}
|
||||||
|
if (isOnline()) {
|
||||||
|
waking = null
|
||||||
|
loop = null
|
||||||
|
onOnline()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (elapsed >= TIMEOUT_S) {
|
||||||
|
waking = waking?.copy(timedOut = true)
|
||||||
|
loop = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
elapsed++
|
||||||
|
waking = waking?.copy(seconds = elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */
|
||||||
|
const val TIMEOUT_S = 90
|
||||||
|
|
||||||
|
/** Re-send the magic packet this often. */
|
||||||
|
const val RESEND_EVERY_S = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||||
|
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||||
|
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||||
|
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||||
|
* timed out) while the touch buttons work for a pointer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||||
|
val w = waker.waking ?: return
|
||||||
|
|
||||||
|
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||||
|
if (gamepadUi) {
|
||||||
|
// A retries once timed out; B falls through to the BackHandler above.
|
||||||
|
GamepadNavEffect2D(
|
||||||
|
active = true,
|
||||||
|
onDirection = {},
|
||||||
|
onActivate = { if (w.timedOut) waker.retry() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
|
// Swallow taps so the home behind can't be touched while waking.
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(40.dp)
|
||||||
|
.widthIn(max = 380.dp)
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(Color(0xF01A1730))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||||
|
.padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
if (w.timedOut) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.size(34.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${w.hostName} didn't wake",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 19.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"It may still be booting, or it's powered off / off this network.",
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||||
|
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text(
|
||||||
|
"Waking ${w.hostName}…",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 19.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Waiting for it to come online · ${w.seconds}s",
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||||
|
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ fun HostCard(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
onRename: (() -> Unit)? = null,
|
onEdit: (() -> Unit)? = null,
|
||||||
onWake: (() -> Unit)? = null,
|
onWake: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||||
@@ -108,7 +108,7 @@ fun HostCard(
|
|||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null || onRename != null || onWake != null) {
|
if (onForget != null || onEdit != null || onWake != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||||
@@ -129,12 +129,12 @@ fun HostCard(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onRename != null) {
|
if (onEdit != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Rename") },
|
text = { Text("Edit…") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menu = false
|
menu = false
|
||||||
onRename()
|
onEdit()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -83,7 +83,7 @@ internal fun HostsScene() {
|
|||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||||
items(SAVED) { h ->
|
items(SAVED) { h ->
|
||||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
|
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ android {
|
|||||||
ndkVersion = ndkVer
|
ndkVersion = ndkVer
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 31
|
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
|
||||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
// Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV
|
||||||
|
// boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
@@ -28,6 +30,9 @@ android {
|
|||||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
|
||||||
|
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
|||||||
// find their subtools.
|
// find their subtools.
|
||||||
val cmd = mutableListOf(
|
val cmd = mutableListOf(
|
||||||
"$cargoBin/cargo", "ndk",
|
"$cargoBin/cargo", "ndk",
|
||||||
"-t", "arm64-v8a", "-t", "x86_64",
|
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||||
"--platform", "31",
|
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||||
|
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||||
|
"--platform", "28",
|
||||||
"-o", file("src/main/jniLibs").absolutePath,
|
"-o", file("src/main/jniLibs").absolutePath,
|
||||||
"build", "-p", "punktfunk-client-android",
|
"build", "-p", "punktfunk-client-android",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
@@ -16,7 +17,8 @@ import java.nio.ByteBuffer
|
|||||||
/**
|
/**
|
||||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||||
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||||
|
* player-LED via `LightsManager` (API 33+); adaptive
|
||||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||||
*
|
*
|
||||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||||
@@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
private var hidoutThread: Thread? = null
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
private var vm: VibratorManager? = null
|
private var vm: VibratorManager? = null
|
||||||
|
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||||
|
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||||
|
private var legacy: Vibrator? = null
|
||||||
private var vibratorIds: IntArray = IntArray(0)
|
private var vibratorIds: IntArray = IntArray(0)
|
||||||
private var amplitudeControlled = false
|
private var amplitudeControlled = false
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
rumbleThread?.interrupt()
|
rumbleThread?.interrupt()
|
||||||
hidoutThread?.interrupt()
|
hidoutThread?.interrupt()
|
||||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||||
|
runCatching { legacy?.cancel() }
|
||||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||||
@@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
rgbLight = null
|
rgbLight = null
|
||||||
playerLight = null
|
playerLight = null
|
||||||
vm = null
|
vm = null
|
||||||
|
legacy = null
|
||||||
vibratorIds = IntArray(0)
|
vibratorIds = IntArray(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,39 +118,65 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val m = dev.vibratorManager
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
val ids = m.vibratorIds
|
val m = dev.vibratorManager
|
||||||
if (ids.isEmpty()) {
|
val ids = m.vibratorIds
|
||||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
if (ids.isEmpty()) {
|
||||||
return
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vm = m
|
||||||
|
vibratorIds = ids
|
||||||
|
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||||
|
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||||
|
} else {
|
||||||
|
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = dev.vibrator
|
||||||
|
if (!v.hasVibrator()) {
|
||||||
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
legacy = v
|
||||||
|
amplitudeControlled = v.hasAmplitudeControl()
|
||||||
|
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||||
}
|
}
|
||||||
vm = m
|
|
||||||
vibratorIds = ids
|
|
||||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
|
||||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||||
private fun renderRumble(low: Int, high: Int) {
|
private fun renderRumble(low: Int, high: Int) {
|
||||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||||
val m = vm ?: return
|
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
if (lo == 0 && hi == 0) {
|
val m = vm
|
||||||
m.cancel() // (0,0) = stop
|
if (m != null) {
|
||||||
|
if (lo == 0 && hi == 0) {
|
||||||
|
m.cancel() // (0,0) = stop
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val combo = CombinedVibration.startParallel()
|
||||||
|
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||||
|
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||||
|
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||||
|
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||||
|
} else {
|
||||||
|
// Single motor or no amplitude control: blend both into one effect.
|
||||||
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
|
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||||
|
}
|
||||||
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val combo = CombinedVibration.startParallel()
|
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
val lv = legacy ?: return
|
||||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
if (lo == 0 && hi == 0) {
|
||||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
lv.cancel() // (0,0) = stop
|
||||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
return
|
||||||
} else {
|
}
|
||||||
// Single motor or no amplitude control: blend both into one effect.
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
runCatching {
|
||||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
}
|
}
|
||||||
runCatching { m.vibrate(combo.combine()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package io.unom.punktfunk.kit.library
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a
|
||||||
|
// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the
|
||||||
|
// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and
|
||||||
|
// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork
|
||||||
|
// schema in crates/punktfunk-host/src/library.rs.
|
||||||
|
|
||||||
|
/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */
|
||||||
|
const val DEFAULT_MGMT_PORT = 47990
|
||||||
|
|
||||||
|
/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */
|
||||||
|
data class Artwork(val portrait: String?, val header: String?, val hero: String?) {
|
||||||
|
/** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */
|
||||||
|
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||||
|
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||||
|
val isCustom: Boolean get() = store == "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||||
|
sealed class LibraryResult {
|
||||||
|
data class Ok(val games: List<GameEntry>) : LibraryResult()
|
||||||
|
data class Unauthorized(val message: String) : LibraryResult()
|
||||||
|
data class Error(val message: String) : LibraryResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
object LibraryClient {
|
||||||
|
/**
|
||||||
|
* `GET https://<address>:<mgmtPort>/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned
|
||||||
|
* host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank
|
||||||
|
* value means the host was never connected/paired, so there's nothing authorized to browse.
|
||||||
|
* BLOCKING — call from a background dispatcher.
|
||||||
|
*/
|
||||||
|
fun fetch(
|
||||||
|
address: String,
|
||||||
|
mgmtPort: Int = DEFAULT_MGMT_PORT,
|
||||||
|
certPem: String,
|
||||||
|
keyPem: String,
|
||||||
|
fpHex: String,
|
||||||
|
): LibraryResult {
|
||||||
|
if (fpHex.isBlank()) {
|
||||||
|
return LibraryResult.Unauthorized(
|
||||||
|
"Connect to this host once first — the library uses the identity created on pairing to authenticate.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val client = try {
|
||||||
|
mtlsHttpClient(certPem, keyPem, address, fpHex)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}")
|
||||||
|
}
|
||||||
|
val base = "https://$address:$mgmtPort"
|
||||||
|
val req = Request.Builder().url("$base/api/v1/library").build()
|
||||||
|
return try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base))
|
||||||
|
401 -> LibraryResult.Unauthorized(
|
||||||
|
"The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.",
|
||||||
|
)
|
||||||
|
else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LibraryResult.Error(
|
||||||
|
"Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(json: String, base: String): List<GameEntry> {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
val out = ArrayList<GameEntry>(arr.length())
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val art = o.optJSONObject("art") ?: JSONObject()
|
||||||
|
out.add(
|
||||||
|
GameEntry(
|
||||||
|
id = o.optString("id"),
|
||||||
|
store = o.optString("store"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
art = Artwork(
|
||||||
|
portrait = resolveArt(str(art, "portrait"), base),
|
||||||
|
header = resolveArt(str(art, "header"), base),
|
||||||
|
hero = resolveArt(str(art, "hero"), base),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A present, non-null, non-blank JSON string field, else null. */
|
||||||
|
private fun str(o: JSONObject, key: String): String? =
|
||||||
|
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
|
||||||
|
|
||||||
|
/** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */
|
||||||
|
private fun resolveArt(s: String?, base: String): String? =
|
||||||
|
if (s != null && s.startsWith("/")) base + s else s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||||
|
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||||
|
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||||
|
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||||
|
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||||
|
*/
|
||||||
|
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||||
|
val clientCert = CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate
|
||||||
|
val privateKey = parsePrivateKey(keyPem)
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(null, null)
|
||||||
|
setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert))
|
||||||
|
}
|
||||||
|
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
|
kmf.init(keyStore, CharArray(0))
|
||||||
|
|
||||||
|
// System default trust manager, for non-host (external CDN) origins.
|
||||||
|
val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
sysTmf.init(null as KeyStore?)
|
||||||
|
val sysTm = sysTmf.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
|
||||||
|
val pinned = fpHex.lowercase()
|
||||||
|
val trustManager = object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host
|
||||||
|
sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust
|
||||||
|
}
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = sysTm.acceptedIssuers
|
||||||
|
}
|
||||||
|
|
||||||
|
val ssl = SSLContext.getInstance("TLS")
|
||||||
|
ssl.init(kmf.keyManagers, arrayOf<TrustManager>(trustManager), null)
|
||||||
|
|
||||||
|
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||||
|
val verifier = HostnameVerifier { hostname, session ->
|
||||||
|
hostname == host || defaultVerifier.verify(hostname, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.sslSocketFactory(ssl.socketFactory, trustManager)
|
||||||
|
.hostnameVerifier(verifier)
|
||||||
|
.connectTimeout(8, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */
|
||||||
|
private fun parsePrivateKey(pem: String): PrivateKey {
|
||||||
|
val body = pem
|
||||||
|
.replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "")
|
||||||
|
.replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "")
|
||||||
|
.replace(Regex("\\s"), "")
|
||||||
|
val der = Base64.getDecoder().decode(body)
|
||||||
|
val spec = PKCS8EncodedKeySpec(der)
|
||||||
|
for (alg in listOf("EC", "RSA", "Ed25519")) {
|
||||||
|
try {
|
||||||
|
return KeyFactory.getInstance(alg).generatePrivate(spec)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// try the next algorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Hex(der: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) }
|
||||||
@@ -74,6 +74,16 @@ class KnownHostStore(context: Context) {
|
|||||||
save(h.copy(name = newName))
|
save(h.copy(name = newName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||||
|
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||||
|
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||||
|
*/
|
||||||
|
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||||
|
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||||
|
save(updated)
|
||||||
|
}
|
||||||
|
|
||||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||||
fun all(): List<KnownHost> =
|
fun all(): List<KnownHost> =
|
||||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||||
@@ -89,4 +99,22 @@ class KnownHostStore(context: Context) {
|
|||||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||||
|
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||||
|
* an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`.
|
||||||
|
*/
|
||||||
|
fun parseMacs(s: String): List<String> = s
|
||||||
|
.split(',', ';', ' ', '\n', '\t')
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { m ->
|
||||||
|
// Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts
|
||||||
|
// a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.)
|
||||||
|
m.split(":").let { o ->
|
||||||
|
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package io.unom.punktfunk.kit.security
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||||
|
class KnownHostStoreTest {
|
||||||
|
@Test
|
||||||
|
fun parsesAndNormalizesSingleMac() {
|
||||||
|
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesMultipleSeparators() {
|
||||||
|
val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66"))
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66"))
|
||||||
|
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsMalformedEntries() {
|
||||||
|
// Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC.
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs(""))
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("not-a-mac"))
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet
|
||||||
|
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
|
||||||
|
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,11 @@ android_logger = "0.14"
|
|||||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
|
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||||
|
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
|
||||||
|
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||||
|
# linked, so the .so still loads on API 28/29.
|
||||||
|
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ use ndk::media::media_codec::{
|
|||||||
OutputBuffer,
|
OutputBuffer,
|
||||||
};
|
};
|
||||||
use ndk::media::media_format::MediaFormat;
|
use ndk::media::media_format::MediaFormat;
|
||||||
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
|
use ndk::native_window::NativeWindow;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
use punktfunk_core::session::Frame;
|
use punktfunk_core::session::Frame;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::ffi::c_void;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -113,11 +114,13 @@ pub fn run(
|
|||||||
mode.height
|
mode.height
|
||||||
);
|
);
|
||||||
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
// Tell the display the stream's refresh so Android can pick a matching display mode and align
|
||||||
// vsync (no 60-in-120 judder on high-refresh panels). minSdk 31 ≥ API 30, so the underlying
|
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
|
||||||
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
|
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
|
||||||
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
|
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
|
||||||
log::warn!(
|
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
|
||||||
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
|
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) {
|
||||||
|
log::debug!(
|
||||||
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,6 +343,32 @@ fn boost_thread_priority() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib
|
||||||
|
/// still loads on our API-28 floor — a hard import of a >floor symbol makes `dlopen`/`System.load`
|
||||||
|
/// fail on every API-28/29 device, even where this path is never hit. Mirrors the dlsym approach in
|
||||||
|
/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol
|
||||||
|
/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy.
|
||||||
|
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool {
|
||||||
|
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||||
|
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||||
|
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||||
|
// process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30),
|
||||||
|
// checked before transmuting the non-null pointer to its fn-pointer type. `window.ptr()` is the
|
||||||
|
// live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||||
|
unsafe {
|
||||||
|
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||||
|
if lib.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||||
|
if sym.is_null() {
|
||||||
|
return false; // device API < 30 — no per-surface frame-rate hint
|
||||||
|
}
|
||||||
|
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||||
|
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||||
|
|||||||
Reference in New Issue
Block a user