forked from unom/punktfunk
Compare commits
320
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca2ff7093a | ||
|
|
a2dc011200 | ||
|
|
48eeae7527 | ||
|
|
0df4ca957f | ||
|
|
13aa11355e | ||
|
|
e989d7457f | ||
|
|
b05bb1dd48 | ||
|
|
2898f6b049 | ||
|
|
4eb4e3465b | ||
|
|
44cd5bfd81 | ||
|
|
8977228a4b | ||
|
|
6a82a602a1 | ||
|
|
fc3b2d0328 | ||
|
|
1c1fd7d9bc | ||
|
|
1c60e641b3 | ||
|
|
eda4b7ebd2 | ||
|
|
730ac43169 | ||
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
0519b057d5 | ||
|
|
33b029695f | ||
|
|
1f6f01cb76 | ||
|
|
fade2f7af3 | ||
|
|
6e4cc335c5 | ||
|
|
d9662c010d | ||
|
|
2b0913cf53 | ||
|
|
1280f697be | ||
|
|
3b39710a5a | ||
|
|
19243c30b4 | ||
|
|
dfcffcdd50 | ||
|
|
f2b5b3e567 | ||
|
|
6d7e6f71c0 | ||
|
|
9e3fba10c1 | ||
|
|
fd4f032d20 | ||
|
|
892e683f0e | ||
|
|
7af6c323d0 | ||
|
|
76e6618b84 | ||
|
|
7d2a8778d1 | ||
|
|
6007bc42cd | ||
|
|
16d54b73a1 | ||
|
|
d60b1dda29 | ||
|
|
d7fa5847f1 | ||
|
|
e473a4be7b | ||
|
|
675030935a | ||
|
|
21d9190324 | ||
|
|
7ae8866a5c | ||
|
|
a8099e0f5b | ||
|
|
b4b24f8b57 | ||
|
|
c23fc84bef | ||
|
|
0e5a059098 | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
b03acc9153 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
245173a731 | ||
|
|
230d253b06 | ||
|
|
def215ae8e | ||
|
|
dfcc530ee7 | ||
|
|
6b5307618f | ||
|
|
cdacd5636e | ||
|
|
47f01149bf | ||
|
|
59ef285f08 | ||
|
|
5bec450402 | ||
|
|
cd6ce34892 | ||
|
|
20568d988f | ||
|
|
2a60f94f74 | ||
|
|
60406c9d72 | ||
|
|
65f697651e | ||
|
|
a00c4d2a6a | ||
|
|
e12ef6633c | ||
|
|
38a0f54b09 | ||
|
|
c420ae4676 | ||
|
|
bfd0de8973 | ||
|
|
1dd5df0127 | ||
|
|
08c45b96eb | ||
|
|
a52d60e242 | ||
|
|
c2f5e91b3d | ||
|
|
44cb7f7815 | ||
|
|
52b89a1592 | ||
|
|
57446f9ed9 | ||
|
|
a4f6e259e3 | ||
|
|
6d82716598 | ||
|
|
f584eebb92 | ||
|
|
cd0a370229 | ||
|
|
3301f5aa60 | ||
|
|
1f0b12de6d | ||
|
|
3def50a88a | ||
|
|
8f9e451395 | ||
|
|
0d22333831 | ||
|
|
47602f7e59 | ||
|
|
f415c7d090 | ||
|
|
e86c6367e1 | ||
|
|
0fd44d8242 | ||
|
|
6807d7951c | ||
|
|
f50721aedb | ||
|
|
5d3301ed9b | ||
|
|
bd140bd232 | ||
|
|
d161c12680 | ||
|
|
7537e8e6b2 | ||
|
|
2b81bd286f | ||
|
|
5711fafa38 | ||
|
|
fa946a16b9 | ||
|
|
9a29eb4a7a | ||
|
|
1b52942bf8 | ||
|
|
6bec7c7cc6 | ||
|
|
8f4e71f8dc | ||
|
|
9ddf802665 | ||
|
|
b6ca692c13 | ||
|
|
f7eb844274 | ||
|
|
a75ed71428 | ||
|
|
b551f7dae8 | ||
|
|
e5046a2811 | ||
|
|
19d37c44b3 | ||
|
|
5be399a4f6 | ||
|
|
6c32890014 | ||
|
|
9c33bc9397 | ||
|
|
fcdb2a53de | ||
|
|
601f040ffe | ||
|
|
eac308412c | ||
|
|
65c4b4b17e | ||
|
|
329df4c1f4 | ||
|
|
5fc5da3256 | ||
|
|
a8922b454a | ||
|
|
790db5edbb | ||
|
|
82d39011ce | ||
|
|
8a4eac4c41 | ||
|
|
7e4fe80793 | ||
|
|
c4cf53c1fc | ||
|
|
d59a1a9606 | ||
|
|
7c411f7ef4 | ||
|
|
9e47f746ba | ||
|
|
13f8a1c5cd | ||
|
|
d5f2c63367 | ||
|
|
4e03dcc280 | ||
|
|
37813199b5 | ||
|
|
9cefa0a3ea | ||
|
|
83f6164027 | ||
|
|
dd097d1ef2 | ||
|
|
01946aa123 | ||
|
|
c0dcac7fa2 | ||
|
|
654c09d067 | ||
|
|
ab88a8fb40 | ||
|
|
66249710b9 | ||
|
|
454531030d | ||
|
|
a64a22ccfc | ||
|
|
3717466594 | ||
|
|
c7c9500e89 | ||
|
|
cfbde6aec7 | ||
|
|
0eb8f2d0f1 | ||
|
|
59cc234055 | ||
|
|
242292528c | ||
|
|
0f7d724154 | ||
|
|
475ff70a2a | ||
|
|
d4db2e3331 | ||
|
|
c49b648032 | ||
|
|
c94dafd4be | ||
|
|
abb084aac3 | ||
|
|
3eede724d1 | ||
|
|
4b5a37dae2 | ||
|
|
f24eb02692 | ||
|
|
e053292a80 | ||
|
|
ed075b98dd | ||
|
|
ec44079db4 | ||
|
|
b81aee6821 | ||
|
|
412991f6a3 | ||
|
|
e43d67c721 | ||
|
|
ba227057b6 | ||
|
|
428fef2b20 | ||
|
|
0a6a49a9aa | ||
|
|
e356e354f2 | ||
|
|
f737414949 | ||
|
|
dff2769ba9 | ||
|
|
f3a69d5b56 | ||
|
|
6d1c7187d3 | ||
|
|
84db02ef6d | ||
|
|
20f766799c | ||
|
|
62119e553a | ||
|
|
5aebb1ace4 | ||
|
|
d5462d6d3d | ||
|
|
8cff5bda6b | ||
|
|
4ce7a3c11f | ||
|
|
a7eadbb680 | ||
|
|
f5a5d19a3d | ||
|
|
97a94b0a55 | ||
|
|
92f953fa39 | ||
|
|
071c4041e4 | ||
|
|
c7be3bb9d0 | ||
|
|
a3872ed4df | ||
|
|
70da960a37 | ||
|
|
582759b3cb | ||
|
|
baf011f7a7 | ||
|
|
25b97b5092 | ||
|
|
849a991c3a | ||
|
|
aec6ccf887 | ||
|
|
af2f51e9cc | ||
|
|
a823bcf6ae | ||
|
|
2541436e35 | ||
|
|
f9b5673d78 | ||
|
|
8effeb792a | ||
|
|
dc2d946949 | ||
|
|
aa070d1f4a | ||
|
|
eeed8ae8bb | ||
|
|
5c6236aec9 | ||
|
|
a8ee7cd71b | ||
|
|
838892ce1c | ||
|
|
95637f3226 | ||
|
|
a519491928 | ||
|
|
b52733d287 | ||
|
|
faa00ed142 | ||
|
|
4cd35e15ca | ||
|
|
700275fa0d | ||
|
|
6352ff629d | ||
|
|
33538582e2 | ||
|
|
8e8cc84d1a | ||
|
|
af1717e9f4 | ||
|
|
1c9aec35fe | ||
|
|
223647b58f | ||
|
|
7f969cc429 | ||
|
|
77a8ccbbcd | ||
|
|
b65e787fca | ||
|
|
a17a8917f4 | ||
|
|
3c53f484fc | ||
|
|
e68f411f34 | ||
|
|
9a163d4ebf | ||
|
|
7535afe8b0 | ||
|
|
74faee315d | ||
|
|
e2b37c6050 | ||
|
|
52aedb30c3 | ||
|
|
7e15160918 | ||
|
|
6e46734484 | ||
|
|
5b2f795b13 | ||
|
|
08f154be07 | ||
|
|
6a049b490b | ||
|
|
b93815ac6e | ||
|
|
9a64a0faef | ||
|
|
a44b08455f | ||
|
|
9e561f1404 | ||
|
|
1eef3c9799 | ||
|
|
44692a0dc1 | ||
|
|
d0c0e8cc47 | ||
|
|
22fdea66ff | ||
|
|
8f577f19ab | ||
|
|
e50941c839 | ||
|
|
abaac704d7 | ||
|
|
88d071c033 | ||
|
|
a602e7cf91 | ||
|
|
08142744a1 | ||
|
|
d7b6b7e7cd | ||
|
|
1d2efd501f | ||
|
|
dfa53ed6f0 | ||
|
|
455f83ebfd | ||
|
|
c52b3c4736 | ||
|
|
a4bf4c276e | ||
|
|
aa2287ce15 | ||
|
|
bb94dce7b0 | ||
|
|
d1a9456e1c | ||
|
|
a308ca337f | ||
|
|
ca1f36ac62 | ||
|
|
8e82a175ee | ||
|
|
610f8b9cbe | ||
|
|
77834be4e4 | ||
|
|
d2b86b103f | ||
|
|
68486b6ac8 | ||
|
|
639886357d | ||
|
|
3bfed02e02 | ||
|
|
ab3ef5b208 | ||
|
|
d92266a402 | ||
|
|
451f95079e | ||
|
|
d927fcb2b0 | ||
|
|
c354d7cd09 | ||
|
|
c804a0dc9f | ||
|
|
be8183caab | ||
|
|
a289f7b9af | ||
|
|
50ef253e43 | ||
|
|
2314637c0d | ||
|
|
46d648cf0f | ||
|
|
adf895ced1 | ||
|
|
18fdc4cc6b | ||
|
|
7ba344cf4e | ||
|
|
e6476eddcf | ||
|
|
47c9162433 | ||
|
|
a9ffd3becd | ||
|
|
362be21ebb | ||
|
|
bca85db160 | ||
|
|
b347682eb2 | ||
|
|
f206feb588 | ||
|
|
cd8e19a7d5 | ||
|
|
47d8301aeb | ||
|
|
6bbdafc289 | ||
|
|
0196c75769 | ||
|
|
f30f7e88b0 | ||
|
|
ec500b0943 | ||
|
|
63312e0a19 | ||
|
|
75eaf9ad91 | ||
|
|
d5838f381a | ||
|
|
14bdfeaeeb | ||
|
|
ae6d872bed | ||
|
|
b949bf2062 | ||
|
|
d8f7984477 | ||
|
|
bee924162a | ||
|
|
45c37f1a3b | ||
|
|
ec1e7be7dd | ||
|
|
b4255631ac | ||
|
|
a81c6ac789 | ||
|
|
88d29d3615 | ||
|
|
099904967d | ||
|
|
d09b4668c6 | ||
|
|
cef4b849e7 | ||
|
|
761895ec46 | ||
|
|
435962ba38 | ||
|
|
7e97e600c7 | ||
|
|
368cd3c13d |
@@ -0,0 +1,6 @@
|
||||
<!-- What and why — the diff says how. -->
|
||||
|
||||
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
|
||||
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
|
||||
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
|
||||
catches the mechanical half.)
|
||||
@@ -94,6 +94,14 @@ env:
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# The Skia console (pf-console-ui over skia-bindings, design/android-skia-console-port.md):
|
||||
# prebuilt Skia archives for all three Android ABIs come from our own release
|
||||
# (git.unom.io/unom/skia-binaries, tag = the skia-bindings version) — kit/build.gradle.kts
|
||||
# bakes that url template in as the default, so this workflow needs no configuration. The
|
||||
# repo variable, when set, OVERRIDES it (e.g. to stage the next skia-safe bump's archives
|
||||
# before they are released). 🛑 skia-bindings never fails when no archive matches — it
|
||||
# silently builds Skia from source. Every ABI's log must show `DOWNLOAD AND INSTALL SUCCEEDED`.
|
||||
SKIA_BINARIES_URL: ${{ vars.SKIA_BINARIES_URL }}
|
||||
|
||||
jobs:
|
||||
android:
|
||||
@@ -222,6 +230,19 @@ jobs:
|
||||
# screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v*
|
||||
# tags) and have no business adding a minute to every push. The filter is what lets the
|
||||
# contract gate here without dragging the rest of the app suite in with it.
|
||||
#
|
||||
# ⚠ The filter is an ALLOWLIST, so a test class that is not named here does not run — it
|
||||
# reads as coverage in the tree and gates nothing. `ProfilesTest` and `StatsOverlayAudioTest`
|
||||
# sat outside it and were only noticed when the hi-res audio work added cases to both; the
|
||||
# HUD ones had never run in CI at all. Adding a test class to `app/src/test` is therefore
|
||||
# only half the job: add it here too, or it is decoration.
|
||||
#
|
||||
# That audit found NINE more in the same state (gamepad palette/rows/UI, OS icons, render
|
||||
# scale, safe area, SC2 bluetooth grant, settings scope, speed test) — every one of them
|
||||
# passing, so nothing was hiding, but none of them gating either. They are all listed now.
|
||||
# The list is deliberately explicit rather than a package glob: the unfiltered task also
|
||||
# drags in the ~20 Roborazzi screenshot scenes above, and a glob would quietly re-admit them
|
||||
# the moment someone added one.
|
||||
- name: console parity vectors + app-module logic tests
|
||||
working-directory: clients/android
|
||||
run: >-
|
||||
@@ -231,6 +252,17 @@ jobs:
|
||||
--tests 'io.unom.punktfunk.GamepadSettingsLayoutTest'
|
||||
--tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest'
|
||||
--tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest'
|
||||
--tests 'io.unom.punktfunk.ProfilesTest'
|
||||
--tests 'io.unom.punktfunk.StatsOverlayAudioTest'
|
||||
--tests 'io.unom.punktfunk.GamepadPaletteTest'
|
||||
--tests 'io.unom.punktfunk.GamepadSettingsRowsTest'
|
||||
--tests 'io.unom.punktfunk.GamepadUiTest'
|
||||
--tests 'io.unom.punktfunk.OsIconsTest'
|
||||
--tests 'io.unom.punktfunk.RenderScaleTest'
|
||||
--tests 'io.unom.punktfunk.SafeAreaTest'
|
||||
--tests 'io.unom.punktfunk.Sc2BluetoothGrantTest'
|
||||
--tests 'io.unom.punktfunk.SettingsScopeTest'
|
||||
--tests 'io.unom.punktfunk.SpeedTestTest'
|
||||
--stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
|
||||
@@ -248,7 +248,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
|
||||
+52
-2
@@ -175,6 +175,19 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The deep half of the docs-drift gates (the `docs-drift` job checks the docs-site copy
|
||||
# and the textual rest): the committed spec must match what the binary actually serves.
|
||||
# Build already compiled punktfunk-host with default features, so this re-links at worst.
|
||||
# Byte diff on purpose — the generator is deterministic, and if that ever stops being
|
||||
# true it deserves to surface here.
|
||||
- name: OpenAPI spec drift gate
|
||||
run: |
|
||||
cargo run -p punktfunk-host --locked -- openapi > /tmp/openapi.regen.json
|
||||
diff -u api/openapi.json /tmp/openapi.regen.json >/dev/null || {
|
||||
echo "::error::api/openapi.json is stale — regenerate: cargo run -p punktfunk-host -- openapi > api/openapi.json && cp api/openapi.json docs-site/public/openapi.json"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
@@ -326,8 +339,19 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
|
||||
# has the why: this box runs many jobs in parallel and drops packets under that load).
|
||||
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
|
||||
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
|
||||
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
|
||||
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
|
||||
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
|
||||
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
|
||||
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
|
||||
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
|
||||
# 30s is enough to ride out a load burst without making that wait a minute and a half.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: it generates the orval API client + paraglide messages that
|
||||
# typechecking imports.
|
||||
- name: Build
|
||||
@@ -355,8 +379,9 @@ jobs:
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# Retried — see the web job above; this is the job the flake was measured on.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -390,3 +415,28 @@ jobs:
|
||||
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
|
||||
- name: bun.nix drift gate
|
||||
run: sh scripts/ci/check-bun-nix.sh
|
||||
|
||||
# Docs drift gates — pure git-grep textual checks, no cargo, no bun install (the deep half,
|
||||
# regenerating the OpenAPI spec from the built host, rides in the `rust` job above). Same
|
||||
# reasoning as bun-nix for being UNFILTERED: docs drift arrives through commits that look
|
||||
# unrelated to docs — a renamed env var, a removed subcommand, a moved page.
|
||||
docs-drift:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
|
||||
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
|
||||
# still exist, data/platforms.json parses.
|
||||
- name: Docs drift gates
|
||||
run: sh scripts/ci/check-docs-drift.sh
|
||||
# Internal links only: /docs/* page links in docs-site content, relative file links in
|
||||
# the repo's markdown. External URLs and #anchors are deliberately not checked.
|
||||
- name: Docs link check
|
||||
run: sh scripts/ci/check-docs-links.sh
|
||||
|
||||
@@ -231,7 +231,9 @@ jobs:
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
|
||||
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
|
||||
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
|
||||
# NEITHER build: it just (re)places the compose files and pulls the already-published
|
||||
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
|
||||
# dispatch without triggering those rebuilds.
|
||||
# Deploy-only: bring up the unom-1 pieces that live in THIS repo but whose normal deploys
|
||||
# are coupled to heavy build workflows — docs to docker.yml's 5-image matrix, the flatpak
|
||||
# server to flatpak.yml's full flatpak-builder run, the nix cache to nix.yml's full Rust
|
||||
# build. This workflow does NONE of those builds: it just (re)places the compose files and
|
||||
# pulls the already-published images, so unom/infra's deploy-all can bring a fresh unom-1
|
||||
# fully up in a single dispatch without triggering those rebuilds.
|
||||
#
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
# nix-cache -> brings up the caddy:2-alpine Nix binary cache on :3250. Same content/config
|
||||
# split: the cache CONTENT is republished by nix.yml on the next main push
|
||||
# that moves the flake. An empty cache is harmless — every path 404s and
|
||||
# users build from source, which is the pre-cache status quo.
|
||||
# winget -> brings up the winget REST source on :3240; catalogue shipped by
|
||||
# windows-host.yml on stable tags.
|
||||
#
|
||||
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
|
||||
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
|
||||
@@ -100,6 +106,46 @@ jobs:
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
nix-cache:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync nix cache compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-nix-cache/ (drop the packaging/nix/server/ prefix).
|
||||
source: "packaging/nix/server/compose.production.yml,packaging/nix/server/Caddyfile,packaging/nix/server/prune.sh"
|
||||
target: "~/unom-nix-cache"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start nix binary cache server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./site (the cache CONTENT) is NOT shipped here — nix.yml rsyncs it on each main push
|
||||
# that moves the flake, same content/config split as the flatpak repo and the winget
|
||||
# catalogue. Ensure the bind-mount source exists so caddy starts; an empty cache is
|
||||
# harmless, it just 404s every path and users build from source as they do today.
|
||||
mkdir -p ~/unom-nix-cache/site/nar
|
||||
cd ~/unom-nix-cache
|
||||
docker compose -f compose.production.yml up -d
|
||||
# A cache that 404s a miss is healthy; one that cannot answer at all is not.
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3250/nix-cache-info \
|
||||
|| echo "NOTE: no cache content yet - push to main with the flake touched to populate it"
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -118,6 +118,13 @@ jobs:
|
||||
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
|
||||
- image: punktfunk-arch-ci
|
||||
dockerfile: ci/arch-ci.Dockerfile
|
||||
# Flatpak builder (flatpak + flatpak-builder + ostree + the GNOME 50 runtime/SDK
|
||||
# and the rust-stable/llvm20 extensions) — flatpak.yml runs in it. ~5 min of
|
||||
# per-run dnf plus a 168 s restore of a 1.5 GB runtime cache became image layers;
|
||||
# the ci/ tree is also where the runtime pins now live, so a GNOME bump in the
|
||||
# manifest means bumping this Dockerfile's ARGs in the same commit.
|
||||
- image: punktfunk-flatpak-ci
|
||||
dockerfile: ci/flatpak-ci.Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+124
-65
@@ -68,7 +68,18 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
container:
|
||||
# Fedora ships a recent flatpak + flatpak-builder + the kernel userns support.
|
||||
# ci/flatpak-ci.Dockerfile — Fedora 43 with flatpak + flatpak-builder + ostree + node,
|
||||
# and the manifest's Flathub runtime set already installed into /root/.local/share/flatpak.
|
||||
# MEASURED on run 18855: the two dnf steps this replaces cost 303 s and the actions/cache
|
||||
# restore of those runtimes another 168 s, on a job whose actual compile is ~6 min.
|
||||
#
|
||||
# ⚠ BOOTSTRAP (same rule as every other LAN builder — see docker.yml's header): the
|
||||
# registry must already hold :latest. The commit that introduced this image also touches
|
||||
# this file, so both workflows fire together on that one merge and this job can lose the
|
||||
# race to docker.yml's push. That first run fails on the image pull; re-run it once
|
||||
# docker.yml is green. Nothing self-heals it here — a `dnf install` fallback would only
|
||||
# paper over a stale image, and the container never starts to run one anyway.
|
||||
#
|
||||
# --privileged is required for bubblewrap inside the Docker executor (see header).
|
||||
#
|
||||
# --network host is what finally fixed the years-long "Could not resolve
|
||||
@@ -81,7 +92,7 @@ jobs:
|
||||
# default bridge failed too, while the host netns — no embedded resolver in the
|
||||
# path at all — works every time). Host networking also means this job no
|
||||
# longer needs the nsswitch surgery below to be lucky.
|
||||
image: fedora:43
|
||||
image: 192.168.1.58:5010/punktfunk-flatpak-ci:latest
|
||||
options: --privileged --network host
|
||||
steps:
|
||||
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
|
||||
@@ -94,11 +105,12 @@ jobs:
|
||||
# was masked as an intermittent "busy runner drops DNS" and papered over
|
||||
# with retry.sh — but it's deterministic on a runner where the resolve
|
||||
# module tips that way (surfaced when jobs began landing on home-runner-2).
|
||||
# Drop the `resolve` entry so host lookups use plain `dns`. NOTE: this alone is not
|
||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||
# there, right before the first `flatpak` network call.
|
||||
- name: Fix container DNS (drop nss-resolve)
|
||||
# Drop the `resolve` entry so host lookups use plain `dns`. The image already
|
||||
# ships it fixed (and, unlike this job, applies the sed AFTER its last dnf — the
|
||||
# whack-a-mole that needed a second copy in the old Tooling step, because a systemd
|
||||
# upgrade's authselect trigger regenerates the file). Kept as a cheap idempotent
|
||||
# guard for a :latest that lags a ci/ change.
|
||||
- name: Fix container DNS (drop nss-resolve — baked, this is a guard)
|
||||
run: |
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
|
||||
@@ -114,27 +126,30 @@ jobs:
|
||||
# genuine upstream blips.
|
||||
cat /etc/resolv.conf || true
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||
- name: node for the JS actions
|
||||
run: dnf -y install nodejs
|
||||
|
||||
# node comes from the image now (act_runner execs a JS action with the CONTAINER's
|
||||
# node and injects none of its own), so checkout needs no install step ahead of it.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Tooling
|
||||
- name: Tooling (baked — assert, don't install)
|
||||
run: |
|
||||
# flatpak-cargo-generator.py (master) needs aiohttp + tomlkit (NOT the old `toml`).
|
||||
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
|
||||
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
|
||||
gnupg2 rsync openssh-clients
|
||||
# Belt-and-suspenders: keep nsswitch on plain `dns` even if this dnf transaction pulled
|
||||
# in a fresh systemd-resolved (it does — flatpak recommends xdg-desktop-portal ->
|
||||
# pipewire/wireplumber -> systemd-networkd/-resolved). Verified on the real runner
|
||||
# (2026-07-11) this dnf install does NOT actually rewrite /etc/nsswitch.conf — no
|
||||
# authselect trigger fires — so this line alone was never the fix for the failures
|
||||
# below. See the retry.sh bump for the real cause.
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# A GUARD, not an install. Everything below used to be dnf'd here (303 s/run with the
|
||||
# node step); it now lives in ci/flatpak-ci.Dockerfile. If :latest ever lags a change to
|
||||
# that file the fix is to let docker.yml republish it — dnf-ing on top of a stale image
|
||||
# would hide the drift and cost the time this image exists to save. Fail loudly instead.
|
||||
for t in flatpak flatpak-builder ostree git python3 gpg rsync ssh curl jq node; do
|
||||
command -v "$t" >/dev/null \
|
||||
|| { echo "::error::$t is missing from punktfunk-flatpak-ci — docker.yml must republish :latest"; exit 1; }
|
||||
done
|
||||
python3 -c 'import aiohttp, tomlkit' \
|
||||
|| { echo "::error::flatpak-cargo-generator.py's deps (aiohttp/tomlkit) missing from the image"; exit 1; }
|
||||
# The runtimes are baked too, but a miss here is survivable: the prefetch step below
|
||||
# pulls whatever is absent from Flathub, retried. Warn rather than fail — silently
|
||||
# paying ~1.5 GB per run is the failure mode worth naming.
|
||||
flatpak list --user --columns=ref | grep -q . \
|
||||
|| echo "::warning::no Flathub runtimes in the image — the prefetch step will download them (~1.5 GB)"
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
|
||||
# The remote is baked as well; this stays because it is a no-op when present and the
|
||||
# one network call cheap enough to keep as a guard.
|
||||
#
|
||||
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
|
||||
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
|
||||
@@ -153,17 +168,12 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
# This job was the fleet's single heaviest network consumer: every run re-downloaded
|
||||
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
|
||||
# every crate source. Both live in well-defined directories, both are idempotently
|
||||
# verified/extended by the steps below, and the central cache server restores them
|
||||
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
|
||||
# (runtimes/extensions) and manifest+Cargo.lock (crate sources + builder state).
|
||||
- name: Cache Flathub runtimes
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/flatpak
|
||||
key: flatpak-runtimes-${{ hashFiles('packaging/flatpak/**') }}
|
||||
restore-keys: flatpak-runtimes-
|
||||
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and every
|
||||
# crate source. The runtimes were cached here from ~/.local/share/flatpak until
|
||||
# 2026-08-17 and are now IMAGE LAYERS instead (ci/flatpak-ci.Dockerfile). That cache
|
||||
# is not merely redundant now, it is harmful: restoring it would spend 168 s
|
||||
# overwriting the baked installation with an older copy of itself. The crate sources
|
||||
# stay cached — they are keyed on Cargo.lock, which no image can pin.
|
||||
- name: Cache flatpak-builder state (crate sources, ccache)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -215,39 +225,46 @@ jobs:
|
||||
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
|
||||
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
|
||||
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
|
||||
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
|
||||
# so the local repo carries every existing branch; the build below then only ADDS this run's
|
||||
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
|
||||
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# the stable commit's objects were still on disk. Fix: seed every published channel into
|
||||
# the local repo first; the build below then only ADDS this run's commit and the
|
||||
# regenerated+signed summary keeps both channels.
|
||||
#
|
||||
# HOW, and why not the rsync this replaces. Mirroring the whole published repo down over
|
||||
# ssh transferred the entire thing every run, because the local repo starts empty and has
|
||||
# nothing to diff against — rsync said so itself on run 18855:
|
||||
# received 3,835,169,389 bytes … total size is 3,845,084,524 speedup is 1.00
|
||||
# 3.84 GB and 180 s off a Hetzner box, to publish a ~28 MB commit, growing by that much
|
||||
# again every build (the repo is never pruned and the upload runs without --delete). What
|
||||
# the summary actually needs is each channel's TIP, so pull exactly that over HTTP:
|
||||
# `--depth=0` takes the requested commit and none of its parents, and `--mirror` writes
|
||||
# refs/heads/* where build-update-repo looks for them. The current channel's tip is worth
|
||||
# having for a second reason — it is the new commit's parent, so --generate-static-deltas
|
||||
# can still emit the from-parent delta that makes `flatpak update` incremental.
|
||||
#
|
||||
# This needs no deploy secret at all (it reads the public repo), so unlike the ssh version
|
||||
# it also seeds correctly on a fork or a secretless run. gpg verification is off for the
|
||||
# same reason the rsync had none: every object pulled here is re-signed by the deploy step
|
||||
# below before it is republished.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
|
||||
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
|
||||
exit 0
|
||||
fi
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
mkdir -p "$PWD/repo"
|
||||
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS.
|
||||
# Probe first (retried) whether a published repo exists at all: ONLY that case may
|
||||
# continue with a fresh repo. A transient network failure must FAIL the job instead —
|
||||
# a blanket `rsync || continue` here is exactly how a flaky link produces the
|
||||
# single-branch summary that clobbers the other channel (the bug described above).
|
||||
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
|
||||
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
|
||||
if [ "$PRESENT" = present ]; then
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
|
||||
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
|
||||
ostree --repo="$PWD/repo" init --mode=archive
|
||||
ostree --repo="$PWD/repo" remote add --if-not-exists --no-gpg-verify unom "$REPO_URL/repo/"
|
||||
# Probe (retried) whether a published repo exists at all: ONLY a real 404 may continue
|
||||
# with a fresh repo. A transient network failure must FAIL the job instead — treating a
|
||||
# flaky link as "nothing published yet" is exactly how a single-branch summary comes to
|
||||
# clobber the other channel (the bug described above).
|
||||
if bash scripts/ci/retry.sh 5 curl -fsS -o /dev/null "$REPO_URL/repo/summary"; then
|
||||
for ref in $(bash scripts/ci/retry.sh 5 ostree --repo="$PWD/repo" remote refs unom); do
|
||||
case "$ref" in unom:app/$APP_ID/x86_64/*) ;; *) continue ;; esac
|
||||
bash scripts/ci/retry.sh 5 ostree --repo="$PWD/repo" pull --mirror --depth=0 \
|
||||
unom "${ref#unom:}"
|
||||
done
|
||||
elif [ "$(curl -sS -o /dev/null -w '%{http_code}' "$REPO_URL/repo/summary")" = 404 ]; then
|
||||
echo "::warning::no published repo at $REPO_URL (first publish) — continuing fresh"
|
||||
else
|
||||
echo "::warning::no published repo on the server (first publish) — continuing fresh"
|
||||
echo "::error::$REPO_URL/repo/summary unreachable — refusing to build a summary that would drop a channel"
|
||||
exit 1
|
||||
fi
|
||||
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
|
||||
@@ -263,24 +280,52 @@ jobs:
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
# --disable-rofiles-fuse is the container-safe path (no FUSE).
|
||||
# --disable-updates ("only download missing sources, never update to latest vcs
|
||||
# version") is what makes a restored .flatpak-builder cache actually save network:
|
||||
# every `type: git` source in the manifest is pinned to a COMMIT SHA (gamescope, glm,
|
||||
# stb — plus gamescope's submodules, pinned by their gitlinks), so there is nothing to
|
||||
# update to and re-fetching them only buys upstream flakiness. See the build step below
|
||||
# for the mechanism.
|
||||
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
|
||||
# load-sensitive runner, same flathub.org resolution path.
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only \
|
||||
--download-only --disable-updates \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
#
|
||||
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
|
||||
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
|
||||
# build (builder-main.c calls builder_manifest_download() unconditionally — only
|
||||
# --disable-download skips it), and it passes `update_vcs = !--disable-updates`. With
|
||||
# updates on (the default) builder_git_mirror_repo() ALWAYS does a live `git ls-remote`
|
||||
# + `git fetch` for every git source and every submodule, even ones pinned to an
|
||||
# immutable commit sha. So this step re-fetched five repos on each run — gamescope plus
|
||||
# its wlroots / libliftoff / vkroots / libdisplay-info submodules — outside retry.sh,
|
||||
# and one HTTP 503 from gitlab.freedesktop.org killed the job minutes in:
|
||||
# Fetching git repo https://gitlab.freedesktop.org/emersion/libdisplay-info, ref refs/tags/0.3.0
|
||||
# error: RPC failed; HTTP 503 … fatal: expected 'acknowledgments'
|
||||
# Failed to download sources: module gamescope-wsi-layer: … exited with code 128
|
||||
# (Those three submodules are not even built — the module sets enable_gamescope=false;
|
||||
# they get mirrored only because flatpak-builder clones submodules by default.)
|
||||
# With the flag, builder_git_mirror_repo() short-circuits on `git cat-file -e <commit>`
|
||||
# against the warm mirror and returns BEFORE any network call, so an upstream blip can
|
||||
# no longer reach this step. It can never change what is built either: every git source
|
||||
# here is commit-pinned (see the manifest), so "don't update" is a semantic no-op.
|
||||
# Anything genuinely missing still downloads, so a cold state dir self-heals.
|
||||
#
|
||||
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||
# (manifest sets no branch).
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch="$FLATPAK_BRANCH" \
|
||||
--disable-updates \
|
||||
--install-deps-from=flathub \
|
||||
--repo="$PWD/repo" \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
@@ -402,6 +447,20 @@ jobs:
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||
# 4) Bound the published repo. Every canary adds ~28 MB that nothing ever removed —
|
||||
# the upload runs without --delete (deliberately: see above) and the local repo is
|
||||
# no longer a full mirror that could carry a deletion over. Left alone it had
|
||||
# reached 3.84 GB, on a box that has run out of disk before. `ostree prune` is the
|
||||
# safe tool for it: --refs-only touches ONLY commits no ref points at (superseded
|
||||
# canaries), and --keep-younger-than spares anything recent, so a client mid-pull
|
||||
# or a box a few builds behind still resolves every object it asks for. Guarded on
|
||||
# ostree existing there, and never allowed to fail the deploy — the bundle and the
|
||||
# repo are already published by this point, and a full disk is a slower problem
|
||||
# than a red release.
|
||||
bash scripts/ci/retry.sh 3 $SSH "$DEST" \
|
||||
"command -v ostree >/dev/null && ostree --repo=\$HOME/$DEPLOY_DIR/site/repo prune --refs-only --keep-younger-than='30 days ago' \
|
||||
|| echo 'no ostree on the deploy host — repo not pruned'" \
|
||||
|| echo "::warning::prune step failed — published repo may be growing unbounded"
|
||||
|
||||
- name: Attach bundle to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
|
||||
# Runs the script unattended inside a clean container per package family against the REAL
|
||||
# package registry — the one path a textual gate can't cover: does the repo line, the key import
|
||||
# and the install actually work today on a fresh box. `--no-start` because a container has no
|
||||
# user systemd; the script degrades to printing the enable command, which is also under test.
|
||||
#
|
||||
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
|
||||
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
|
||||
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
|
||||
name: installer-smoke
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- scripts/install.sh
|
||||
- data/platforms.json
|
||||
- .gitea/workflows/installer-smoke.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- scripts/install.sh
|
||||
- data/platforms.json
|
||||
- .gitea/workflows/installer-smoke.yml
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: smoke (${{ matrix.family }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# actions/checkout needs git + node + CA certs in the container; curl is the
|
||||
# script's own prerequisite (it says so and stops without it).
|
||||
- family: debian-13
|
||||
image: debian:trixie
|
||||
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
|
||||
- family: fedora-44
|
||||
image: fedora:44
|
||||
prep: dnf install -y -q curl git nodejs
|
||||
- family: arch
|
||||
image: archlinux:base
|
||||
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
steps:
|
||||
- name: Prepare the container (${{ matrix.family }})
|
||||
run: ${{ matrix.prep }}
|
||||
- uses: actions/checkout@v4
|
||||
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
|
||||
# Root without sudo → the script's sudo shim, another path under test.
|
||||
- name: Run the installer unattended
|
||||
run: sh scripts/install.sh --yes --no-start
|
||||
- name: The host is installed and conflict-free
|
||||
run: |
|
||||
punktfunk-host --version
|
||||
punktfunk-host detect-conflicts
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
! command -v punktfunk-host
|
||||
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
|
||||
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
|
||||
@@ -111,3 +111,20 @@ jobs:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
|
||||
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
|
||||
# blocked reusing these shots for the docs. Publish them to the generic package registry
|
||||
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
|
||||
# public repo:
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
|
||||
- name: Publish screenshots to the package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
|
||||
for f in clients/linux/screenshots/*.png; do
|
||||
name=$(basename "$f")
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
|
||||
echo "published $BASE/$name"
|
||||
done
|
||||
|
||||
+187
-19
@@ -4,8 +4,9 @@
|
||||
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
|
||||
# in ci.yml for that story).
|
||||
#
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
# Three tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push — so the two cheap tiers gate every PR and the expensive one runs
|
||||
# only where its cost buys something (a published cache):
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
|
||||
# without building them. Catches the failures that actually happen to this flake — a
|
||||
@@ -32,15 +33,28 @@
|
||||
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
|
||||
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
|
||||
#
|
||||
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
# * cache — PUSH TO MAIN ONLY. Builds the Rust packages + gamescope for real and publishes every
|
||||
# punktfunk store path to the binary cache at https://nix.unom.io, so a NixOS user gets
|
||||
# prebuilt binaries instead of an hour of rustc and a gamescope compile. This is the
|
||||
# expensive tier and it is why the job timeout is 180 rather than 90.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
|
||||
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
|
||||
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
|
||||
# It needs NO extra trigger for releases: a release bumps the workspace version in
|
||||
# Cargo.toml, which is already in the path filter below, so the tag's content is
|
||||
# published by the version-bump commit on main.
|
||||
#
|
||||
# Only OUR paths are published — see the step for why that is both correct and the
|
||||
# difference between ~300 MB and several GB per publish.
|
||||
#
|
||||
# The Rust packages and punktfunk-gamescope are still not built on PRs: they are the expensive ones
|
||||
# and their inputs are already gated by the `rust` job in ci.yml. Build them on a PR by hand on a
|
||||
# Nix box, or with the `build-rust` / `build-gamescope` dispatch inputs below.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope matters more than it looks: `host.gamescopeHdr` DEFAULTS TRUE, so it is on
|
||||
# the critical path of every `services.punktfunk.host.enable = true` build. It patches whatever
|
||||
# gamescope the pinned nixpkgs carries, so a nixpkgs bump — not a change of ours — is what breaks
|
||||
# it, and the first person to find out would be an operator whose system rebuild fails. The `cache`
|
||||
# tier now compiles it on every main push, so a flake.lock bump that breaks it goes red HERE; the
|
||||
# dispatch input below is for checking it on a branch before merging.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
@@ -107,8 +121,15 @@ jobs:
|
||||
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
|
||||
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 90
|
||||
# 180, not 90: the `cache` tier on a main push compiles the whole Rust workspace AND gamescope
|
||||
# from source inside the nix sandbox, where the sccache every other Rust job leans on cannot
|
||||
# reach (no network in a derivation, and RUSTC_WRAPPER is not set inside one).
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
# Where the published cache lives on unom-1, and the URL users substitute from. Kept next to
|
||||
# the flatpak repo (3230) and winget source (3240) — see packaging/nix/server/.
|
||||
DEPLOY_DIR: unom-nix-cache
|
||||
CACHE_URL: https://nix.unom.io
|
||||
# The flake needs both experimental features. Also baked into the installer's --extra-conf
|
||||
# below; this covers any step that shells out before that config is read.
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
@@ -126,11 +147,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
|
||||
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
|
||||
# against a future slim-image swap, and costs one cached apt call.)
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
|
||||
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
|
||||
# already has all but rsync — this is belt-and-braces against a future slim-image swap, and
|
||||
# costs one cached apt call.)
|
||||
- name: Installer prerequisites
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git rsync openssh-client
|
||||
|
||||
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
|
||||
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
|
||||
@@ -151,7 +173,19 @@ jobs:
|
||||
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
|
||||
# here — so record the headroom, or a future failure is a guess.
|
||||
- name: Environment
|
||||
run: df -h / /nix /tmp || true
|
||||
# Disk AND memory. This job's recurring failure is an OOM kill, and `df` cannot explain
|
||||
# one — a run that dies at exit 137 with only disk numbers in the log is a guess.
|
||||
run: |
|
||||
df -h / /nix /tmp || true
|
||||
free -h 2>/dev/null || grep -E '^(MemTotal|MemAvailable|SwapTotal)' /proc/meminfo || true
|
||||
nproc 2>/dev/null || true
|
||||
# THE number for this job's recurring exit 137. `free` and /proc/meminfo report the HOST
|
||||
# inside a container, so they showed 125Gi total / 48Gi available on a run that then got
|
||||
# bun SIGKILLed (19444) — a cgroup cap is invisible to them and is the only remaining
|
||||
# explanation. cgroup v2 first, then v1; "max" means uncapped.
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit readable"
|
||||
|
||||
# Evaluates + instantiates every flake output without building any of it.
|
||||
- name: nix flake check (eval only)
|
||||
@@ -183,10 +217,144 @@ jobs:
|
||||
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
|
||||
echo "bun packages OK: $web $scripting"
|
||||
|
||||
# ── binary cache (push to main only) ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Decided against a bucket on storage.unom.io even though sccache already uses it and the
|
||||
# credentials already exist: it is local RustFS on the home uplink with no CDN in front, so
|
||||
# every NixOS user's download would come off the same pipe every CI runner uses — and S3
|
||||
# answers 403, not 404, for a missing key, which nix treats as a hard error rather than a
|
||||
# cache miss (see packaging/nix/server/Caddyfile). unom-1 already serves the flatpak repo
|
||||
# this way from a cloud IP; a Nix cache is the same static-files-behind-caddy shape.
|
||||
#
|
||||
# Gitea itself cannot host this at all: it has 23 package registry types and none is Nix, and
|
||||
# the binary cache protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
|
||||
# /<hash>.narinfo, /nar/<hash>.nar.xz) that /api/packages/{owner}/generic/… cannot express.
|
||||
- name: Cache publish preflight
|
||||
id: cachecfg
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
|
||||
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
|
||||
# repo deploy does, so this workflow stays green through setup.
|
||||
run: |
|
||||
set -eu
|
||||
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
|
||||
echo "go=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "go=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
|
||||
fi
|
||||
|
||||
- name: Build the publishable packages
|
||||
if: ${{ steps.cachecfg.outputs.go == 'true' }}
|
||||
# Everything a user can install. punktfunk-gamescope earns its place here more than any
|
||||
# other: host.gamescopeHdr DEFAULTS TRUE, so without it in the cache every
|
||||
# `services.punktfunk.host.enable = true` still compiles a compositor from source.
|
||||
run: |
|
||||
"$NIX" build --print-build-logs \
|
||||
.#punktfunk-host .#punktfunk-client .#punktfunk-tray \
|
||||
.#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope
|
||||
# This is now the heaviest job on the fleet — a full workspace build plus gamescope fills
|
||||
# the store with tens of GB, and this fleet ran a runner out of disk on 2026-08-06. Record
|
||||
# the headroom AFTER the build too, or a future "no space left on device" is a guess.
|
||||
df -h / /nix /tmp || true
|
||||
|
||||
- name: Sign + publish to nix.unom.io
|
||||
if: ${{ steps.cachecfg.outputs.go == 'true' }}
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
|
||||
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
|
||||
# "Illegal option". The two places below where a pipeline's LEFT side must be able to fail
|
||||
# the step are written as redirects instead, so nothing depends on pipefail.
|
||||
set -eu
|
||||
PKGS=".#punktfunk-host .#punktfunk-client .#punktfunk-tray .#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope"
|
||||
|
||||
# 1) Pick what to publish. PUBLISH ONLY OUR OWN PATHS — this is the difference between
|
||||
# ~300 MB and several GB per run, and it is not a corner cut: a runtime closure here
|
||||
# is our binaries plus stock nixpkgs (ffmpeg, gtk4, glibc, …), and every stock path is
|
||||
# already on cache.nixos.org, served by a real CDN. Mirroring them onto unom-1 would
|
||||
# cost disk and home-to-cloud bandwidth to serve a WORSE copy of what users already
|
||||
# have. Nothing in nixpkgs is named punktfunk, so the name filter is exact.
|
||||
paths="$("$NIX" path-info -r $PKGS | grep -- '-punktfunk' || true)"
|
||||
[ -n "$paths" ] || { echo "::error::no punktfunk store paths in the closure — the name filter is broken"; exit 1; }
|
||||
echo "$paths"
|
||||
# The filter is a string match, so it would fail SILENTLY if a pname ever changed — and
|
||||
# the package most likely to drift is gamescope, the most expensive one to lose. Assert
|
||||
# every built output is actually covered rather than discovering it as a user rebuild.
|
||||
for out in $("$NIX" build --print-out-paths $PKGS); do
|
||||
printf '%s\n' "$paths" | grep -qxF "$out" \
|
||||
|| { echo "::error::$out is not matched by the '-punktfunk' filter — publish would silently omit it"; exit 1; }
|
||||
done
|
||||
|
||||
# 2) Sign into a local binary cache. The secret is the whole `name:base64` line from
|
||||
# `nix key generate-secret`; the matching public key is what users pin (README).
|
||||
KEYDIR="$(mktemp -d)"; chmod 700 "$KEYDIR"
|
||||
printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$KEYDIR/key"; chmod 600 "$KEYDIR/key"
|
||||
printf '%s\n' "$paths" | xargs "$NIX" copy --to "file://$PWD/nix-cache?secret-key=$KEYDIR/key"
|
||||
# Publish the PUBLIC half beside the cache and echo it here. Users must pin this key, so
|
||||
# it needs to be fetchable from the cache itself rather than only from a doc that can
|
||||
# drift — and on the first run this log line is where the value for README.md comes from.
|
||||
# Redirect, not `| tee`: without pipefail a failing nix would be masked by tee's success
|
||||
# and publish an EMPTY public key, which every user would then pin.
|
||||
"$NIX" key convert-secret-to-public < "$KEYDIR/key" > nix-cache/punktfunk-cache.pub
|
||||
cat nix-cache/punktfunk-cache.pub
|
||||
rm -rf "$KEYDIR"
|
||||
echo "publishing $(find nix-cache -name '*.narinfo' | wc -l) paths, $(du -sh nix-cache | cut -f1)"
|
||||
|
||||
# 3) Ship it. Same deploy key and retry discipline as flatpak.yml — this runner's link to
|
||||
# unom-1 drops TCP dials under load.
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
|
||||
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
|
||||
# yet is a HARD download failure for whoever fetches it in that window; a NAR nothing
|
||||
# points at yet is simply invisible. rsync renames each file into place atomically, so a
|
||||
# cancelled run (this workflow has cancel-in-progress) can only ever under-publish.
|
||||
# No --delete: superseded paths are aged out by prune.sh below instead, so a client
|
||||
# mid-download is never pulled out from under.
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" nix-cache/nar/ "$DEST:$DEPLOY_DIR/site/nar/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" nix-cache/nix-cache-info nix-cache/punktfunk-cache.pub nix-cache/*.narinfo "$DEST:$DEPLOY_DIR/site/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" \
|
||||
packaging/nix/server/compose.production.yml packaging/nix/server/Caddyfile packaging/nix/server/prune.sh \
|
||||
"$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
|
||||
# 4) Bound it. The flatpak repo next door reached 3.84 GB publishing this same way with
|
||||
# no sweep, on a box that has run out of disk before; this one gets the sweep from the
|
||||
# first publish. Never allowed to fail the job — the cache is already live by now, and
|
||||
# a growing disk is a slower problem than a red main.
|
||||
bash scripts/ci/retry.sh 3 $SSH "$DEST" "sh ~/$DEPLOY_DIR/prune.sh ~/$DEPLOY_DIR/site 180" \
|
||||
|| echo "::warning::cache prune failed — published cache may be growing unbounded"
|
||||
|
||||
# 5) Prove the published cache actually answers, rather than assuming the rsync landed.
|
||||
# A substituter that 200s on nix-cache-info but 403s on a miss is the failure mode that
|
||||
# breaks users' builds, so check both.
|
||||
bash scripts/ci/retry.sh 5 curl -fsS "$CACHE_URL/nix-cache-info"
|
||||
miss="$(curl -sS -o /dev/null -w '%{http_code}' "$CACHE_URL/0000000000000000000000000000000000.narinfo")"
|
||||
[ "$miss" = 404 ] || { echo "::error::cache returns $miss for an absent path; nix needs 404 or every user build fails"; exit 1; }
|
||||
echo "published → $CACHE_URL"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
|
||||
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
|
||||
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
|
||||
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
|
||||
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
|
||||
# step skipped while the job reported success — a green that proved nothing about the
|
||||
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
|
||||
# is least reliable about, which is why this file used github.event.inputs to begin with.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
@@ -196,6 +364,6 @@ jobs:
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
@@ -37,15 +37,18 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
|
||||
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
|
||||
# is the worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
|
||||
@@ -35,6 +35,7 @@ on:
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- 'scripts/alsa-ucm2/**'
|
||||
- '.gitea/workflows/rpm.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||
@@ -175,7 +176,9 @@ jobs:
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
@@ -221,6 +224,15 @@ jobs:
|
||||
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
|
||||
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
|
||||
# fail takes the job down instead of waving the release through.
|
||||
- name: The DualSense UCM drop-in must still bite
|
||||
# scripts/alsa-ucm2/ hooks into alsa-ucm-conf's own dispatcher, so an upstream rename or
|
||||
# reorder can neuter it with no error anywhere — and what comes back is the Spider-Man
|
||||
# EXCEPTION_ACCESS_VIOLATION, not a quieter pad. This is the only leg that runs on a real
|
||||
# Fedora tree, hence the two packages. Skips itself on any box without them.
|
||||
run: |
|
||||
dnf -y install alsa-ucm alsa-ucm-utils
|
||||
sh scripts/ci/check-dualsense-ucm.sh
|
||||
|
||||
- name: Assert the capability matrix (rpm)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
|
||||
@@ -39,8 +39,11 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
|
||||
# worst place to lose to a dropped packet — the tag is already pushed.
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -40,8 +40,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
|
||||
# explicitly since build-storybook has no prebuild hook of its own.
|
||||
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
|
||||
# `Fail extracting tarball` (ci.yml's web job has the measurement).
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Generate API client + i18n messages
|
||||
run: bun run codegen
|
||||
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
|
||||
|
||||
@@ -56,8 +56,12 @@
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
|
||||
# from one assembled layout:
|
||||
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
|
||||
# (stable path Steam can launch: overlay + Big Picture work)
|
||||
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
|
||||
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
@@ -283,6 +287,28 @@ jobs:
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
|
||||
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
|
||||
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
|
||||
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
|
||||
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
|
||||
- name: Pack + sign installer + portable zip
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-client-installer.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
@@ -301,7 +327,10 @@ jobs:
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
# The installer + portable zip (the default download; docs point at these alias URLs).
|
||||
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
|
||||
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
@@ -324,10 +353,11 @@ jobs:
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
|
||||
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
|
||||
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
|
||||
# don't collide.
|
||||
- name: Attach client artifacts to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -335,6 +365,6 @@ jobs:
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for coding agents working in this repository.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues live as Gitea issues in `unom/punktfunk` on `git.unom.io`, driven by the `gitea` MCP server
|
||||
(`gh`/`glab`/`tea` do not work here), and every write needs the user's go-ahead first.
|
||||
See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
The five canonical roles, each label string equal to its name — `needs-triage`, `needs-info`,
|
||||
`ready-for-agent`, `ready-for-human`, `wontfix` — none of which exist in the tracker yet.
|
||||
See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: one `CONTEXT.md` and one `docs/adr/` at the repo root, covering the whole
|
||||
workspace. See `docs/agents/domain.md`.
|
||||
+1935
File diff suppressed because it is too large
Load Diff
+29
-3
@@ -83,15 +83,41 @@ Two more gates that only apply to some changes:
|
||||
instead of waiting for the CI job that compiles it.
|
||||
|
||||
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
|
||||
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
|
||||
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
|
||||
and copy the snapshot the docs site serves:
|
||||
and CI fails if the committed copy drifts. `api/openapi.json` is gated the same way: the `rust` job
|
||||
regenerates the spec and diffs it against the committed file, and the `docs-drift` job checks that
|
||||
`docs-site/public/openapi.json` — the snapshot the docs site serves — is a byte-for-byte copy of it.
|
||||
Touch the management API and CI stays red until you regenerate and re-copy:
|
||||
|
||||
```sh
|
||||
cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||
cp api/openapi.json docs-site/public/openapi.json
|
||||
```
|
||||
|
||||
## Where facts live (docs vs READMEs vs website)
|
||||
|
||||
Every user-facing fact has exactly one canonical home; everything else links to it. Duplicated
|
||||
walkthroughs are how the docs drifted before — don't add new ones.
|
||||
|
||||
| Surface | Owns | Never contains |
|
||||
|---|---|---|
|
||||
| [docs-site](https://docs.punktfunk.unom.io) (`docs-site/content/`) | All user-facing facts: install, config, features, troubleshooting | Design rationale |
|
||||
| READMEs (root, `packaging/*`, `scripts/*`) | Dev/packager rationale and pointers into the docs | User walkthroughs duplicated from docs-site |
|
||||
| [punktfunk.unom.io](https://punktfunk.unom.io) (separate repo) | Marketing, downloads, blog | Instructions — it deep-links the docs instead |
|
||||
| punktfunk-planning (private) | Design rationale, RFCs, plans | Anything user-facing |
|
||||
|
||||
Docs pages are written for one of two audiences, not both at once: the **get-started track**
|
||||
(quickstart, install, pairing — short, one task per page, happy path only) assumes no Linux
|
||||
expertise; the **reference track** (configuration, CLI, API, per-compositor pages) is allowed to be
|
||||
dense. When a change touches a user-facing fact, update the docs-site page that owns it in the same
|
||||
PR.
|
||||
|
||||
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
|
||||
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
|
||||
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
|
||||
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
|
||||
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
|
||||
baseline in the script), and internal docs links must resolve.
|
||||
|
||||
Match the surrounding code's comment density and naming. Commit messages end with the
|
||||
`Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
|
||||
Generated
+48
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3027,11 +3027,12 @@ dependencies = [
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3049,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3057,6 +3058,7 @@ dependencies = [
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
@@ -3071,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3081,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3107,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3120,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3134,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3167,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3189,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3197,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3209,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3218,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3251,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3262,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3273,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3485,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3495,9 +3497,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3505,7 +3508,12 @@ dependencies = [
|
||||
"ndk",
|
||||
"ndk-sys",
|
||||
"opus",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"punktfunk-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
@@ -3513,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3530,21 +3538,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3562,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3572,6 +3582,7 @@ dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"if-addrs",
|
||||
"libc",
|
||||
"log",
|
||||
"opus",
|
||||
"proptest",
|
||||
"quinn",
|
||||
@@ -3594,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3603,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3673,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3687,11 +3698,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
@@ -3710,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.29.0"
|
||||
version = "0.31.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -109,36 +109,11 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
|
||||
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
|
||||
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
|
||||
installed first:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
|
||||
# (on Bazzite take host.env.bazzite instead)
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||
|
||||
systemctl --user enable --now punktfunk-host # the streaming host
|
||||
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
|
||||
```
|
||||
|
||||
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
|
||||
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
|
||||
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
|
||||
line before the replacement — the install guide has the snippet). Then open
|
||||
`https://<host-ip>:47992` and pair.
|
||||
|
||||
How the virtual display and input are wired up depends on your desktop — see
|
||||
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
|
||||
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
|
||||
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway).
|
||||
|
||||
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
|
||||
nothing to run by hand — open the web console and pair. Use
|
||||
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
|
||||
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
|
||||
`setup.exe` over the old install; uninstall from Add/Remove Programs.
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
|
||||
|
||||
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
|
||||
+141
-4
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.29.0"
|
||||
"version": "0.31.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
@@ -1860,6 +1860,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}/running": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Report which of a provider's titles are running",
|
||||
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
|
||||
"operationId": "reportProviderRunning",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The report was accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderRunningAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4242,7 +4305,7 @@
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).",
|
||||
"description": "`launching` (launched, not seen running yet), `running`, `exited`, `untracked` (this title\nexposes nothing the host can recognize its process by, so its exit will never be noticed),\nor `grace` (its session is gone and it will be ended when the reconnect window closes).",
|
||||
"example": "running"
|
||||
},
|
||||
"store": {
|
||||
@@ -6194,6 +6257,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameOnNewLaunch": {
|
||||
"type": "string",
|
||||
"description": "What to do with a title this client already has running when it launches a **different** one.\n\nThe third axis rather than a fourth value on [`GameOnSessionEnd`], because it answers a different\nquestion at a different moment: that one is \"this session is over, what about its game\", this one\nis \"the player asked for something else, what about the last thing\". Folding them together would\ntie two unrelated choices to one switch — an operator who wants a game to survive a disconnect\nvery plausibly still wants it closed when they pick another title.\n\nScoped to the **same client's own launches**, and only ever to launches this host performed\nitself ([`crate::launchreg`]). A game the player started at the machine was never recorded there,\nso it can never be closed by this; nor can another client's game, which would otherwise let one\ndevice end someone else's session mid-play.",
|
||||
"enum": [
|
||||
"keep",
|
||||
"end"
|
||||
]
|
||||
},
|
||||
"GameOnSessionEnd": {
|
||||
"type": "string",
|
||||
"description": "What to do with the launched game when its session ends.",
|
||||
@@ -6617,7 +6688,7 @@
|
||||
},
|
||||
"HostInfo": {
|
||||
"type": "object",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process).",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
|
||||
"required": [
|
||||
"hostname",
|
||||
"uniqueid",
|
||||
@@ -6663,7 +6734,7 @@
|
||||
},
|
||||
"local_ip": {
|
||||
"type": "string",
|
||||
"description": "Best-effort primary LAN IP."
|
||||
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
@@ -7784,6 +7855,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningAccepted": {
|
||||
"type": "object",
|
||||
"description": "The result of a liveness report.",
|
||||
"required": [
|
||||
"matched",
|
||||
"unknown",
|
||||
"ttl_s"
|
||||
],
|
||||
"properties": {
|
||||
"matched": {
|
||||
"type": "integer",
|
||||
"description": "How many reported titles matched an entry this provider currently publishes.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_s": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
|
||||
"minimum": 0
|
||||
},
|
||||
"unknown": {
|
||||
"type": "integer",
|
||||
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRunningInput": {
|
||||
"type": "object",
|
||||
"description": "Request body for `reportProviderRunning`.",
|
||||
"properties": {
|
||||
"running": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunningTitle"
|
||||
},
|
||||
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReleaseDisplayRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `releaseDisplay`.",
|
||||
@@ -7838,6 +7949,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunningTitle": {
|
||||
"type": "object",
|
||||
"description": "One running title in a provider's liveness report.",
|
||||
"required": [
|
||||
"external_id"
|
||||
],
|
||||
"properties": {
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
|
||||
},
|
||||
"pid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuntimeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -8095,6 +8228,10 @@
|
||||
"description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.",
|
||||
"minimum": 0
|
||||
},
|
||||
"game_on_new_launch": {
|
||||
"$ref": "#/components/schemas/GameOnNewLaunch",
|
||||
"description": "End this client's previous game when it launches a different one. See [`GameOnNewLaunch`]."
|
||||
},
|
||||
"game_on_session_end": {
|
||||
"$ref": "#/components/schemas/GameOnSessionEnd",
|
||||
"description": "End the launched game when the session ends. See [`GameOnSessionEnd`]."
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Flatpak CI builder: everything flatpak.yml used to install per run, plus the Flathub
|
||||
# runtime set the manifest declares. Content-keyed and rebuilt only when the ci/ tree
|
||||
# changes (docker.yml `builders`).
|
||||
#
|
||||
# docker build -f ci/flatpak-ci.Dockerfile -t punktfunk-flatpak-ci ci
|
||||
#
|
||||
# MEASURED on run 18855 (2026-08-17), a green 21m23s build, this image removes:
|
||||
# 63 s `dnf -y install nodejs` (act_runner execs JS actions with the CONTAINER's node)
|
||||
# 240 s `Tooling` — 330 packages
|
||||
# 168 s restoring a 1.5 GB actions/cache of ~/.local/share/flatpak
|
||||
# i.e. ~7.8 min of an 8 min preamble in front of a 6 min compile. The runtimes are the
|
||||
# bulk of the image and the reason it exists; a package-only image would leave the
|
||||
# biggest single step in place.
|
||||
#
|
||||
# WHY FEDORA and not the flathub org.flatpak.Builder image: flatpak.yml's job container
|
||||
# must run bubblewrap under --privileged in the act_runner Docker executor, and Fedora
|
||||
# ships a flatpak/flatpak-builder pair recent enough for the manifest with the kernel
|
||||
# userns support already enabled. See flatpak.yml's header for the --privileged and
|
||||
# --network host constraints, both of which still apply to this image.
|
||||
FROM docker.io/library/fedora:43
|
||||
|
||||
# nss-resolve trap, baked. fedora:43's nsswitch.conf is
|
||||
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: `resolve` is nss-resolve
|
||||
# (systemd-resolved), which does not run in a CI container. glibc consumers (git, curl,
|
||||
# dnf) fall through to `dns`; flatpak/ostree's resolver does NOT — the absent-daemon
|
||||
# socket connect trips `[!UNAVAIL=return]` and it reports "[6] Could not resolve
|
||||
# hostname". flatpak.yml carries the same sed as a runtime step and had to repeat it
|
||||
# after its dnf transaction, because a systemd upgrade's authselect trigger regenerates
|
||||
# the file. Doing it here, after the last dnf in the image, ends that whack-a-mole for
|
||||
# every consumer — the workflow's copy stays only as a guard for a lagging :latest.
|
||||
RUN dnf -y install \
|
||||
# the build itself
|
||||
flatpak flatpak-builder \
|
||||
# ostree CLI: the seed step pulls the published channels' tip commits with it
|
||||
# (`dnf install flatpak` brings the LIBRARY, not the binary)
|
||||
ostree \
|
||||
# actions/checkout is a JS action and act_runner does not inject a node
|
||||
nodejs git git-lfs \
|
||||
# flatpak-cargo-generator.py needs aiohttp + tomlkit (NOT the old `toml`)
|
||||
python3 python3-aiohttp python3-tomlkit \
|
||||
# sign the OSTree repo + rsync it to unom-1; jq/curl for the registry publish
|
||||
gnupg2 rsync openssh-clients curl jq \
|
||||
&& dnf clean all \
|
||||
&& sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf \
|
||||
&& ! grep -q 'resolve \[!UNAVAIL=return\]' /etc/nsswitch.conf
|
||||
|
||||
# The Flathub runtime/SDK set, baked into the image's USER installation (/root/.local,
|
||||
# HOME=/root in the job container) — the same path flatpak.yml used to restore from
|
||||
# actions/cache, so `flatpak-builder --user` finds these with no further wiring.
|
||||
#
|
||||
# ⚠ KEEP IN SYNC with packaging/flatpak/io.unom.Punktfunk.yml: GNOME_VERSION is the
|
||||
# manifest's `runtime-version`, FREEDESKTOP_VERSION is what flatpak-builder resolves the
|
||||
# two `sdk-extensions` to (it prints them as "Dependency Extension: … 25.08"). A drift
|
||||
# here is not fatal — flatpak.yml's prefetch step downloads whatever is missing from
|
||||
# Flathub, retried — but it silently costs ~1.5 GB and several minutes per run, which is
|
||||
# precisely what this image exists to avoid. Bump both in the same commit as the manifest.
|
||||
#
|
||||
# `flatpak install` HERE, at docker-build time, needs no privileges — VERIFIED 2026-08-17
|
||||
# in a plain `docker run` container where bwrap was proven broken first
|
||||
# ("bwrap: No permissions to creating new namespace"): the install still exited 0 and
|
||||
# `flatpak info --user` resolved the ref. flatpak's post-deploy triggers are the only
|
||||
# part that wants bwrap and they are best-effort, so this layer does NOT need the
|
||||
# --privileged that the CONSUMING job needs for flatpak-builder's actual sandbox.
|
||||
#
|
||||
# Related refs (org.freedesktop.Platform.GL.default{,-extra}, codecs-extra,
|
||||
# org.gnome.Platform.Locale) come along by default and are deliberately kept: they are
|
||||
# what `flatpak-builder --install-deps-only` would otherwise pull on every run, so a
|
||||
# --no-related image would look smaller and cost more. org.gnome.Platform//50 alone
|
||||
# unpacks to 2.4 GB; the whole set is the same content the 1.5 GB (compressed) runtime
|
||||
# actions/cache already carried — this moves it from the cache server to the registry,
|
||||
# where Docker keeps it on the runner's disk instead of re-extracting it every run.
|
||||
ARG GNOME_VERSION=50
|
||||
ARG FREEDESKTOP_VERSION=25.08
|
||||
RUN flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo \
|
||||
&& flatpak install --user -y --noninteractive flathub \
|
||||
"org.gnome.Platform//${GNOME_VERSION}" \
|
||||
"org.gnome.Sdk//${GNOME_VERSION}" \
|
||||
"org.freedesktop.Sdk.Extension.rust-stable//${FREEDESKTOP_VERSION}" \
|
||||
"org.freedesktop.Sdk.Extension.llvm20//${FREEDESKTOP_VERSION}" \
|
||||
# Assert rather than trust. `flatpak install` treats its post-deploy triggers as
|
||||
# best-effort and this RUN's exit status would not notice a ref that failed to
|
||||
# deploy; an image that merely LOOKS warm would push the 1.5 GB back onto every
|
||||
# single run, where it reads as "flatpak is slow again" instead of a broken image.
|
||||
# Fail the image build loudly instead — docker.yml goes red and :latest never moves.
|
||||
&& for ref in \
|
||||
"org.gnome.Platform//${GNOME_VERSION}" \
|
||||
"org.gnome.Sdk//${GNOME_VERSION}" \
|
||||
"org.freedesktop.Sdk.Extension.rust-stable//${FREEDESKTOP_VERSION}" \
|
||||
"org.freedesktop.Sdk.Extension.llvm20//${FREEDESKTOP_VERSION}" \
|
||||
; do flatpak info --user "$ref" >/dev/null || exit 1; done \
|
||||
&& flatpak list --user --columns=ref
|
||||
@@ -1,6 +1,8 @@
|
||||
# Gradle / Android build artifacts
|
||||
.gradle/
|
||||
build/
|
||||
# Kotlin compiler daemon session files, written by any Gradle invocation.
|
||||
.kotlin/
|
||||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
|
||||
@@ -19,38 +19,29 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
// The prompts that say the SAME thing in both interfaces.
|
||||
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
|
||||
// first) and a body — and drawn as a Material `AlertDialog`.
|
||||
//
|
||||
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
|
||||
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
|
||||
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
|
||||
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
|
||||
// stopped telling console users which layer Apply would write to at all.
|
||||
//
|
||||
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
|
||||
// body — and what stays per-interface is only how that description is drawn. That split is the
|
||||
// whole point: a copy change now lands in both places because there is only one place.
|
||||
//
|
||||
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
|
||||
// and an editable device name on touch; four D-pad digit slots on the console — different input
|
||||
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
|
||||
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
|
||||
// touch one grows a row per profile).
|
||||
// History: these used to be drawn a second way, as the Compose console's glass card, and the two
|
||||
// renderers drifted by hand until the descriptions were shared here. The Compose console is gone
|
||||
// (the console is the Skia shell now — design/android-skia-console-port.md — and it draws its own
|
||||
// pairing/trust screens), so only the touch renderer remains; the shared-description shape stays
|
||||
// because it is the right shape regardless.
|
||||
|
||||
/** One button of a prompt. [primary] lifts it into the confirm slot; the rest lay out beside. */
|
||||
class DialogAction(
|
||||
val label: String,
|
||||
val primary: Boolean = false,
|
||||
val enabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* One prompt, drawn as whichever interface is running.
|
||||
*
|
||||
* [actions] is ordered PRIMARY FIRST — the console stacks them in that order with the cursor on
|
||||
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
|
||||
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
|
||||
*
|
||||
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
|
||||
* while [ConsoleModal] is a plain Box in the calling tree — which is also why the console one
|
||||
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
|
||||
* One prompt. [actions] is ordered PRIMARY FIRST — the first (or the one flagged primary) becomes
|
||||
* `confirmButton`, the rest lay out beside it.
|
||||
*/
|
||||
@Composable
|
||||
fun PunktfunkDialog(
|
||||
gamepadUi: Boolean,
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
actions: List<DialogAction>,
|
||||
@@ -62,10 +53,6 @@ fun PunktfunkDialog(
|
||||
dismissOnOutsideTap: Boolean = true,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
if (gamepadUi) {
|
||||
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
|
||||
return
|
||||
}
|
||||
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
|
||||
val rest = actions.filter { it !== primary }
|
||||
AlertDialog(
|
||||
@@ -90,28 +77,25 @@ fun PunktfunkDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** A prompt's body paragraph, dimmed to sit under the title in either interface. */
|
||||
/** A prompt's body paragraph, dimmed to sit under the title. */
|
||||
@Composable
|
||||
private fun PromptText(text: String, gamepadUi: Boolean) {
|
||||
val ink = LocalGamepadInk.current
|
||||
private fun PromptText(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
fun TrustNewHostPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Trust this host?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
@@ -120,12 +104,11 @@ fun TrustNewHostPrompt(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi)
|
||||
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}…", gamepadUi) }
|
||||
PromptText("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}…") }
|
||||
PromptText(
|
||||
"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.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,13 +116,11 @@ fun TrustNewHostPrompt(
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
fun FingerprintChangedPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Host identity changed",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
@@ -151,7 +132,6 @@ fun FingerprintChangedPrompt(
|
||||
"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.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,14 +143,12 @@ fun FingerprintChangedPrompt(
|
||||
*/
|
||||
@Composable
|
||||
fun RequestAccessPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Pairing required",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
@@ -179,11 +157,10 @@ fun RequestAccessPrompt(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi)
|
||||
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
PromptText(
|
||||
"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.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -197,10 +174,8 @@ fun RequestAccessPrompt(
|
||||
* a decision to abandon it.
|
||||
*/
|
||||
@Composable
|
||||
fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
fun AwaitingApprovalPrompt(hostLabel: String, onCancel: () -> Unit) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
@@ -216,17 +191,16 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Approve this device on $hostLabel.",
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$label”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -238,13 +212,11 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
*/
|
||||
@Composable
|
||||
fun LocalNetworkPrompt(
|
||||
gamepadUi: Boolean,
|
||||
onAllow: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Allow local network access",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
@@ -256,12 +228,10 @@ fun LocalNetworkPrompt(
|
||||
PromptText(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
|
||||
"or reach any host until you allow it.",
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(
|
||||
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
|
||||
"system settings.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -274,17 +244,14 @@ fun LocalNetworkPrompt(
|
||||
*/
|
||||
@Composable
|
||||
fun SpeedTestPrompt(
|
||||
gamepadUi: Boolean,
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Network speed test",
|
||||
onDismiss = onDismiss,
|
||||
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
|
||||
@@ -308,31 +275,29 @@ fun SpeedTestPrompt(
|
||||
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
PromptText(hostName, gamepadUi)
|
||||
PromptText(hostName)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
|
||||
SpeedTestPhase.Connecting -> PromptText("Connecting…")
|
||||
SpeedTestPhase.Measuring ->
|
||||
PromptText(
|
||||
"Measuring — the host is bursting test traffic for two seconds.",
|
||||
gamepadUi,
|
||||
)
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is SpeedTestPhase.Done -> {
|
||||
Text(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
PromptText(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(speedTestTargetNote(target), gamepadUi)
|
||||
PromptText(speedTestTargetNote(target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +49,19 @@ 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 androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.console.SkiaConsole
|
||||
import io.unom.punktfunk.console.SkiaConsoleShell
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.models.Tab
|
||||
import kotlin.math.roundToInt
|
||||
@@ -76,16 +82,48 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
|
||||
// later manual Back out of the library is not undone by a stale value.
|
||||
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
|
||||
// Which host's game library the TOUCH shell is showing, and which of its pinned cards opened it
|
||||
// (null = the host's own card). Held here beside `tab` rather than inside the tab content: it is
|
||||
// a PUSH over the whole shell, and a `remember` down in ConnectScreen would not survive the
|
||||
// stream that a launch off the shelf starts — which is exactly what `reopenLibrary` restores.
|
||||
var touchLibrary by remember { mutableStateOf<Pair<KnownHost, String?>?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
|
||||
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(
|
||||
// …AND the native console host is in this build (every shipping ABI today; the sysprop
|
||||
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
|
||||
// console to draw, a controller drives the touch UI through Compose's own focus.
|
||||
val skiaConsole = remember { SkiaConsole.wanted() }
|
||||
// …AND it actually came up: a console whose native create failed or whose render thread died
|
||||
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
|
||||
// screen with a working pad probe, which is worse than the touch UI it replaced.
|
||||
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// System bars have ONE owner: this effect. The stream and the console shell both want the
|
||||
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
|
||||
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
|
||||
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
|
||||
// the incoming screen's hide — console → stream left the status and gesture bars parked over
|
||||
// the video. Keyed on the resolved intent, not the screens.
|
||||
val immersive = session != null || gamepadUi
|
||||
DisposableEffect(immersive) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
if (immersive) {
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
|
||||
@@ -116,6 +154,21 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// The touch shell's half of "come back to the library this game was launched from" — the console
|
||||
// shell consumes the same intent on its way in (see GamepadShell). Gated on which shell is up so
|
||||
// exactly one of the two ever claims it, and cleared here so a later manual Back stays backed
|
||||
// out. A host forgotten while the game ran simply leaves us on the host grid.
|
||||
LaunchedEffect(reopenLibrary, gamepadUi) {
|
||||
if (gamepadUi) return@LaunchedEffect
|
||||
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
|
||||
KnownHostStore(context).all().firstOrNull { it.id == id }?.let { kh ->
|
||||
// A pin unpinned while the game was running is no longer a shelf: fall back to the
|
||||
// host's own, rather than a card that no longer exists.
|
||||
touchLibrary = kh to pinId?.takeIf { it in kh.pinnedProfileIds }
|
||||
}
|
||||
reopenLibrary = null
|
||||
}
|
||||
|
||||
// The console backdrop's colour family, published once from the live settings rather than
|
||||
// threaded through every screen that draws a backdrop. Because it is read from the SAME
|
||||
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
|
||||
@@ -146,10 +199,27 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// The console keeps its stack across the stream and wants to know how the session
|
||||
// ended — a clean end is no toast, an abnormal one says why (the desktop shell's
|
||||
// exact contract).
|
||||
if (skiaConsole) {
|
||||
SkiaConsole.sessionEnded(
|
||||
when (reason) {
|
||||
SessionEndReason.NONE, SessionEndReason.LOCAL,
|
||||
SessionEndReason.GAME_EXITED, SessionEndReason.HOST_ENDED -> null
|
||||
SessionEndReason.HOST_ERROR -> "the host reported an error"
|
||||
SessionEndReason.LOST -> "the connection was lost"
|
||||
},
|
||||
)
|
||||
}
|
||||
session = null
|
||||
}
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
// The console: the same Skia shell the Linux/Windows session binary shows, drawn by
|
||||
// native onto a SurfaceView (design/android-skia-console-port.md) — the Compose
|
||||
// console it replaced is gone. `gamepadUi` already folds in whether the native host
|
||||
// is present on this build (see `SkiaConsole.wanted` in the `gamepadUi` resolution).
|
||||
SkiaConsoleShell(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
onConnected = { session = it },
|
||||
@@ -158,6 +228,19 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
reopenLibrary = reopenLibrary,
|
||||
onReopenLibraryHandled = { reopenLibrary = null },
|
||||
)
|
||||
} else if (touchLibrary != null) {
|
||||
// The touch shell's library is a PUSHED screen, not a tab: it belongs to one host, and a
|
||||
// third permanent tab for something you reach from a card would be a nav item that is
|
||||
// meaningless until you pick one. So it takes the whole window (bar included) and Back — the arrow or the system gesture — returns to the grid.
|
||||
// Read once: `touchLibrary` is a `var`, so it does not smart-cast through the branch.
|
||||
val (host, pinId) = touchLibrary!!
|
||||
LibraryScreen(
|
||||
host = host,
|
||||
settings = settings,
|
||||
onLaunched = { session = it },
|
||||
onBack = { touchLibrary = null },
|
||||
pinnedProfileId = pinId,
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
|
||||
@@ -193,6 +276,9 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
// "Browse library…" in a card's overflow — the touch route to the shelf
|
||||
// the console shell reaches with Y.
|
||||
onOpenLibrary = { kh, pinId -> touchLibrary = kh to pinId },
|
||||
)
|
||||
Tab.Settings -> SettingsScreen(
|
||||
initial = settings,
|
||||
@@ -254,194 +340,6 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/**
|
||||
* Which console screen the gamepad shell is showing, and how deep it sits — Home is the root, and
|
||||
* everything reachable from it is one level in. The DEPTH is what decides whether a change is a
|
||||
* push or a pop, and therefore which way the screens travel.
|
||||
*/
|
||||
private enum class GamepadScreen(val depth: Int) {
|
||||
Home(0),
|
||||
Settings(1),
|
||||
Library(1),
|
||||
// Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely
|
||||
// what makes Settings → Controllers travel like a push and the way back like a pop. Give one of
|
||||
// these depth 1 and the transition would read as a sideways swap between two peers.
|
||||
Controllers(2),
|
||||
Licenses(2),
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
/**
|
||||
* Open this library shelf instead of Home on the way in — set when a game launched from it has
|
||||
* just exited. Null (the default) starts on Home exactly as before.
|
||||
*/
|
||||
reopenLibrary: LibraryReturn? = null,
|
||||
onReopenLibraryHandled: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
|
||||
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
|
||||
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
|
||||
var libraryPinId by remember { mutableStateOf<String?>(null) }
|
||||
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
|
||||
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
|
||||
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
|
||||
// equivalent of a browser losing your scroll position on Back. Held here because this is the
|
||||
// only thing that outlives the screen.
|
||||
var settingsPlace by remember { mutableStateOf<GpSettingsPlace?>(null) }
|
||||
|
||||
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
|
||||
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
|
||||
// A host that has since been forgotten simply leaves us on Home rather than failing.
|
||||
LaunchedEffect(reopenLibrary) {
|
||||
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
|
||||
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
|
||||
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
|
||||
// either order happens to work — but this one cannot be broken by a later edit that adds a
|
||||
// suspending call. A host that has since been forgotten just leaves us on Home.
|
||||
KnownHostStore(context).all()
|
||||
.firstOrNull { it.id == id }
|
||||
// A pin unpinned while the game was running is no longer a shelf: fall back to the
|
||||
// host's own, rather than a card that no longer exists.
|
||||
?.let { kh ->
|
||||
libraryHost = kh
|
||||
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
|
||||
screen = GamepadScreen.Library
|
||||
}
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// The console's screen transition, and the desktop console's contract rather than a plain
|
||||
// cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the
|
||||
// incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the
|
||||
// leaving screen sliding down and the revealed one growing back. Direction comes from the
|
||||
// screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack.
|
||||
//
|
||||
// Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid-
|
||||
// transition 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 moves.
|
||||
val animated = animationsEnabled()
|
||||
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||
// Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a
|
||||
// reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel
|
||||
// further than every other dp in the same animation.
|
||||
val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt()
|
||||
AnimatedContent(
|
||||
targetState = screen,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
// Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy
|
||||
// the frozen backdrop pays.
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else if (targetState.depth > initialState.depth) {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease()) +
|
||||
slideInVertically(ConsoleMotion.ease()) { slidePx } +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE)
|
||||
)
|
||||
} else {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
slideOutVertically(ConsoleMotion.ease()) { slidePx }
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "consoleScreen",
|
||||
) { s ->
|
||||
when (s) {
|
||||
GamepadScreen.Home -> ConnectScreen(
|
||||
settings = settings,
|
||||
onConnected = onConnected,
|
||||
onSettingsChange = onSettingsChange,
|
||||
deepLink = deepLink,
|
||||
onDeepLinkHandled = onDeepLinkHandled,
|
||||
gamepadUi = true,
|
||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||
onOpenLibrary = { host, pinId ->
|
||||
libraryHost = host
|
||||
libraryPinId = pinId
|
||||
screen = GamepadScreen.Library
|
||||
},
|
||||
navGate = s == screen,
|
||||
)
|
||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||
initial = settings,
|
||||
onChange = onSettingsChange,
|
||||
// Leaving for HOME forgets the place: coming back in from the carousel should start
|
||||
// at the top of the first section, exactly as it always has. Only a sub-screen's
|
||||
// Back is a return.
|
||||
onBack = { screen = GamepadScreen.Home; settingsPlace = null },
|
||||
navActive = s == screen,
|
||||
resume = settingsPlace,
|
||||
onPlace = { settingsPlace = it },
|
||||
onOpenControllers = { screen = GamepadScreen.Controllers },
|
||||
onOpenLicenses = { screen = GamepadScreen.Licenses },
|
||||
)
|
||||
GamepadScreen.Controllers -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Licenses -> ConsoleLicensesScreen(
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
LibraryScreen(
|
||||
host = host,
|
||||
settings = settings,
|
||||
onLaunched = onConnected,
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
|
||||
navActive = s == screen,
|
||||
pinnedProfileId = libraryPinId,
|
||||
)
|
||||
} ?: 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
|
||||
|
||||
// --- Showing a TOUCH-written screen on the console's field -------------------------------------
|
||||
//
|
||||
// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside
|
||||
// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a
|
||||
// screen that was written for one interface onto the other's field — rather than about the console's
|
||||
// own material.
|
||||
|
||||
/**
|
||||
* Re-inks a screen written against the TOUCH theme so it can be shown on the console's field.
|
||||
*
|
||||
|
||||
@@ -82,6 +82,14 @@ internal fun ConnectGrid(
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
|
||||
libraryEnabled: Boolean,
|
||||
/**
|
||||
* Open this card's game library. The second argument is the shelf's pinned profile id, exactly
|
||||
* as [onConnect] takes the card's one-off: browsing IS this card's connect with a title picked
|
||||
* first, so a pinned card's shelf launches with that card's profile.
|
||||
*/
|
||||
onBrowseLibrary: (KnownHost, StreamProfile?) -> Unit,
|
||||
onRescan: () -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
) {
|
||||
@@ -90,6 +98,13 @@ internal fun ConnectGrid(
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
// Browsing IS a connect-shaped action — this card's connect with a title picked first — so
|
||||
// a PINNED card offers it too, and its shelf launches with that card's profile. Without it
|
||||
// the touch home had no route to the library at all: the console shell reaches it with Y
|
||||
// from a tile, and a finger has no Y.
|
||||
if (libraryEnabled) {
|
||||
add(HostMenuItem("Browse library…") { onBrowseLibrary(kh, pin) })
|
||||
}
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
|
||||
fun ConnectOverlay(
|
||||
connectingHostName: String?,
|
||||
waker: WakeController,
|
||||
gamepadUi: Boolean,
|
||||
onCancelConnect: () -> Unit,
|
||||
) {
|
||||
val waking = waker.waking
|
||||
@@ -124,19 +123,8 @@ fun ConnectOverlay(
|
||||
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
|
||||
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
|
||||
|
||||
if (gamepadUi) {
|
||||
BackHandler { cancel() }
|
||||
// A retries once a wake has timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
|
||||
)
|
||||
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
} else {
|
||||
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
|
||||
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
}
|
||||
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
|
||||
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,117 +167,3 @@ internal fun ConnectModal(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
|
||||
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
|
||||
* out) — glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
|
||||
* user without a working pad can still get out.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectTakeover(
|
||||
phase: ConnectPhase,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Swallow taps so the screen behind can't be touched through the takeover.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
// The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In
|
||||
// landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text
|
||||
// would otherwise sit under the camera.
|
||||
Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
if (timedOut) {
|
||||
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = ink.fg(0.9f),
|
||||
modifier = Modifier.size(46.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PulsingSpinner()
|
||||
}
|
||||
Text(
|
||||
copy.title,
|
||||
color = ink.fg,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
copy.subtitle,
|
||||
color = ink.fg(0.65f),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
)
|
||||
}
|
||||
val hints = buildList {
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
|
||||
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
|
||||
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
|
||||
* expand and fade on a staggered loop — a small sign of life so the takeover reads as working, not
|
||||
* stalled.
|
||||
*/
|
||||
@Composable
|
||||
private fun PulsingSpinner() {
|
||||
val ink = LocalGamepadInk.current
|
||||
val transition = rememberInfiniteTransition(label = "connectPulse")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "pulse",
|
||||
)
|
||||
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val maxR = size.minDimension / 2f
|
||||
for (i in 0..1) {
|
||||
val p = (pulse + i * 0.5f) % 1f
|
||||
drawCircle(
|
||||
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
|
||||
radius = maxR * (0.42f + p * 0.58f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
color = ink.fg,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import io.unom.punktfunk.models.PendingTrust
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectPrompts(
|
||||
gamepadUi: Boolean,
|
||||
/** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */
|
||||
identity: ClientIdentity?,
|
||||
profiles: List<StreamProfile>,
|
||||
@@ -38,17 +37,6 @@ internal fun ConnectPrompts(
|
||||
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
|
||||
awaitingHostName: String?,
|
||||
onCancelApproval: () -> Unit,
|
||||
// ---- console host options (Up on a saved carousel tile) ---------------------------------
|
||||
optionsTarget: HostCardEntry?,
|
||||
onDismissOptions: () -> Unit,
|
||||
libraryEnabled: Boolean,
|
||||
onOpenLibrary: (KnownHost, String?) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onEditHost: (KnownHost) -> Unit,
|
||||
onForgetHost: (KnownHost) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
// ---- speed test --------------------------------------------------------------------------
|
||||
speedTest: HostCardEntry?,
|
||||
/** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */
|
||||
@@ -81,104 +69,51 @@ internal fun ConnectPrompts(
|
||||
// against four D-pad digit slots is a different input model, not a different skin.
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
|
||||
gamepadUi, pt,
|
||||
pt,
|
||||
onTrust = { onTrustNew(pt) },
|
||||
onPairInstead = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) }
|
||||
FingerprintChangedPrompt(pt, onPair) { onPendingTrustChange(null) }
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
|
||||
gamepadUi, pt,
|
||||
pt,
|
||||
onRequestAccess = { onRequestAccess(pt) },
|
||||
onUsePin = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> {
|
||||
val onSavePaired = { fp: String -> onPaired(pt, fp) }
|
||||
if (gamepadUi) {
|
||||
GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
} else {
|
||||
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
}
|
||||
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
awaitingHostName?.let { hostLabel ->
|
||||
AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val offline = !isOnline(kh)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = { onDismissOptions(); onWake(kh) },
|
||||
// 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. A PIN card
|
||||
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
|
||||
// start the card, not a property of the host.
|
||||
onLibrary = if (libraryEnabled) {
|
||||
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ onDismissOptions(); onSpeedTest(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) },
|
||||
onEdit = { onDismissOptions(); onEditHost(kh) },
|
||||
onForget = { onForgetHost(kh); onDismissOptions() },
|
||||
onDismiss = onDismissOptions,
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
if (speedTest != null && speedTestTarget != null) {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase,
|
||||
speedTest.host.name, speedTestTarget, speedTestPhase,
|
||||
onApplySpeedTest, onDismissSpeedTest,
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { kh ->
|
||||
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 = onDismissEdit,
|
||||
editHost = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
onSave = onSaveHost,
|
||||
// Shared clipboard and the profile binding — the two host decisions that used to
|
||||
// exist only in the touch edit sheet, which a TV box has no way to reach.
|
||||
profiles = profiles,
|
||||
)
|
||||
} else {
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = onDismissEdit,
|
||||
)
|
||||
}
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = onDismissEdit,
|
||||
)
|
||||
}
|
||||
|
||||
if (lnpPrompt) {
|
||||
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
|
||||
// returns instantly without a system prompt — hence the settings deep link alongside).
|
||||
LocalNetworkPrompt(
|
||||
gamepadUi,
|
||||
onAllow = onAllowLocalNetwork,
|
||||
onSettings = onOpenSystemSettings,
|
||||
onDismiss = onDismissLnpPrompt,
|
||||
@@ -186,12 +121,10 @@ internal fun ConnectPrompts(
|
||||
}
|
||||
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep.
|
||||
ConnectOverlay(
|
||||
connectingHostName = connectingHostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = onCancelConnect,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
@@ -95,15 +93,10 @@ fun ConnectScreen(
|
||||
// Writes the global defaults back. Only the speed test uses it — that is the one action on this
|
||||
// screen that can land in the defaults layer (design/client-settings-profiles.md §5.3).
|
||||
onSettingsChange: (Settings) -> 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 = {},
|
||||
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
|
||||
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
|
||||
// Raised by "Browse library…" in a card's overflow.
|
||||
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
|
||||
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
|
||||
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
|
||||
// link must go through all of them, not around them.
|
||||
@@ -295,9 +288,6 @@ fun ConnectScreen(
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host being edited (name / address / port / MAC).
|
||||
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<HostCardEntry?>(null) }
|
||||
|
||||
// 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
|
||||
@@ -635,22 +625,11 @@ fun ConnectScreen(
|
||||
// host's binding, exactly like a tap on it does.
|
||||
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
|
||||
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
val copied = clipboard != null && runCatching {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
|
||||
}.isSuccess
|
||||
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
|
||||
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
|
||||
// we say so — a silent menu item reads as a broken one.
|
||||
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
|
||||
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
|
||||
// The console home renders neither the notice nor the status banner, so there it has to be a
|
||||
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
|
||||
when {
|
||||
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
copied -> notice = message
|
||||
else -> status = message
|
||||
}
|
||||
val copied = putLinkOnClipboard(context, url)
|
||||
val message = linkCopyMessage(copied) ?: return
|
||||
// A success dressed as an error banner is a small lie: the notice line for a copy, the
|
||||
// status line for a failure.
|
||||
if (copied) notice = message else status = message
|
||||
}
|
||||
|
||||
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
|
||||
@@ -762,101 +741,57 @@ fun ConnectScreen(
|
||||
|
||||
fun forgetHost(kh: KnownHost) {
|
||||
knownHostStore.remove(kh)
|
||||
// A forgotten host leaves no list of what somebody plays, and no record of what they were
|
||||
// playing, behind on the device. Its record id is the key both are filed under, so this is
|
||||
// the last moment either can be found.
|
||||
io.unom.punktfunk.kit.library.LibraryCache.standard(context.cacheDir).forget(kh.id)
|
||||
LibraryPosition.forget(context, kh.id)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
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.
|
||||
GamepadHome(
|
||||
tiles = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onAddHost = { showManualSheet = true },
|
||||
),
|
||||
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.
|
||||
// ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`,
|
||||
// and is — right up until the measurement finishes: `startSpeedTest` clears `connecting`
|
||||
// before its Done/Failed card is dismissed, so from that moment the card AND the
|
||||
// carousel underneath both consumed the pad. One A then dismissed the card and started
|
||||
// a connect. Every other modal on this screen is named here for exactly this reason.
|
||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||
speedTest == null && waker.waking == null && !lnpPrompt,
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { tile ->
|
||||
tile.knownHost?.let { kh ->
|
||||
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ConnectGrid(
|
||||
savedHosts = savedHosts,
|
||||
discovered = discovered,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
reachable = reachable,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
connecting = connecting,
|
||||
notice = notice,
|
||||
status = status,
|
||||
lnpGranted = lnpGranted,
|
||||
onAskLocalNetwork = { lnpPrompt = true },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onForget = { kh -> forgetHost(kh) },
|
||||
onEdit = { kh -> editTarget = kh },
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
onRescan = { discovery.restart() },
|
||||
onAddHost = { showManualSheet = true },
|
||||
)
|
||||
}
|
||||
ConnectGrid(
|
||||
savedHosts = savedHosts,
|
||||
discovered = discovered,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
reachable = reachable,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
connecting = connecting,
|
||||
notice = notice,
|
||||
status = status,
|
||||
lnpGranted = lnpGranted,
|
||||
onAskLocalNetwork = { lnpPrompt = true },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onForget = { kh -> forgetHost(kh) },
|
||||
onEdit = { kh -> editTarget = kh },
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
onBrowseLibrary = { kh, pin -> onOpenLibrary(kh, pin?.id) },
|
||||
onRescan = { discovery.restart() },
|
||||
onAddHost = { showManualSheet = true },
|
||||
)
|
||||
|
||||
// Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are
|
||||
// remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and
|
||||
// reopened. Moving the block without moving that state would quietly change what a dismiss
|
||||
// costs; moving both is a separate decision from this one.
|
||||
if (showManualSheet) {
|
||||
if (gamepadUi) {
|
||||
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { n, addr, p ->
|
||||
showManualSheet = false
|
||||
connect(addr, p, manualName = n)
|
||||
},
|
||||
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) },
|
||||
)
|
||||
}
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
// Which layer a measurement would land in. Resolved here, not in the prompt: it is a question
|
||||
@@ -870,7 +805,6 @@ fun ConnectScreen(
|
||||
// Everything that floats above whichever home was drawn, in one place and in one order — see
|
||||
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
|
||||
ConnectPrompts(
|
||||
gamepadUi = gamepadUi,
|
||||
identity = identity,
|
||||
profiles = profiles,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
@@ -894,16 +828,6 @@ fun ConnectScreen(
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
},
|
||||
optionsTarget = optionsTarget,
|
||||
onDismissOptions = { optionsTarget = null },
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
onOpenLibrary = onOpenLibrary,
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onEditHost = { kh -> editTarget = kh },
|
||||
onForgetHost = { kh -> forgetHost(kh) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
speedTest = speedTest,
|
||||
speedTestTarget = speedTestTarget,
|
||||
speedTestPhase = speedTestPhase,
|
||||
@@ -953,9 +877,6 @@ fun ConnectScreen(
|
||||
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
|
||||
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
|
||||
* host entries, which would fork pairing, trust and renames (design §5.2a).
|
||||
*
|
||||
* The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch
|
||||
* card's overflow menu does, and one currency for "which card is this" keeps the two from drifting.
|
||||
*/
|
||||
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -14,7 +13,6 @@ import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -49,11 +47,8 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
@@ -61,158 +56,34 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
|
||||
* can see about attached input devices, plus a live input test. This exists for exactly the support
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
|
||||
* the app can see about attached input devices, plus a live input test. This exists for exactly
|
||||
* the support case where a pad "doesn't work" - adapters and BT-to-USB dongles often enumerate
|
||||
* with a different identity than the physical pad, or not as a gamepad at all, and punktfunk only
|
||||
* forwards devices Android classifies as gamepad/joystick. This screen makes that visible on the
|
||||
* device itself.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
|
||||
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
|
||||
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
|
||||
* still owns alone is the live input test - the console receives only the aggregated navigation
|
||||
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
|
||||
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
|
||||
* [padInfoOf] and [testRumble] are internal rather than private.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleControllersScreen(
|
||||
internal fun ControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
// Events are OBSERVED (not consumed) while the test is off, which is what keeps the
|
||||
// "Last input" line live while browsing. Nothing else here wants the pad.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val onTestingChange: (Boolean) -> Unit = { testing = it }
|
||||
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -225,7 +96,11 @@ private fun ControllersBody(
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { !it.isVirtual && !Gamepad.isPad(it) }
|
||||
// Everything real that is NOT counted as a controller — including a device that claims
|
||||
// a pad source with no pad hardware behind it, which the Gamepads list above now
|
||||
// rejects. One list or the other, never neither: this screen is where someone looks
|
||||
// when the client's idea of "a pad is attached" disagrees with the room.
|
||||
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(InputManager::class.java)
|
||||
@@ -247,32 +122,37 @@ private fun ControllersBody(
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
// The hold has lasted long enough; the test ends when B is let go (see the probe).
|
||||
var holdSatisfied by remember { mutableStateOf(false) }
|
||||
// The probes below are built ONCE per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they had when the probe was made — the test would consume nothing.
|
||||
// The probes below are built ONCE and then read these for the life of the screen, so
|
||||
// capturing `testing` plainly would freeze the value it had when the probe was made — the
|
||||
// test would consume nothing.
|
||||
val consuming by rememberUpdatedState(testing)
|
||||
// The console's refusal thud, on whatever actuator the driving pad or this device has.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
|
||||
DisposableEffect(observeInput) {
|
||||
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
|
||||
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
|
||||
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
|
||||
// here would kill the pad navigation the arriving screen had just installed. The same
|
||||
// teardown also runs when this screen hands the pad to its own input test and back.
|
||||
DisposableEffect(Unit) {
|
||||
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
|
||||
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
|
||||
// composed, and only the identity removal keeps this screen's teardown from taking the
|
||||
// arriving screen's claim with it. The same teardown also runs when this screen hands the
|
||||
// pad to its own input test and back.
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
// The CORRECTED keycode, so this screen shows the button the stream will send and not
|
||||
// the one Android guessed for a pad it has no key layout for — the two differ on every
|
||||
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
|
||||
// stream is worse than no tester. The raw pair is still reported in "Last input".
|
||||
val code = Gamepad.padKeyCode(event)
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
held[code] = true
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
held[code] = false
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
@@ -296,37 +176,50 @@ private fun ControllersBody(
|
||||
}
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
|
||||
// field report needs to pin an unmapped pad's report order without the device in hand.
|
||||
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
|
||||
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
|
||||
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
|
||||
event.scanCode,
|
||||
raw,
|
||||
if (code != event.keyCode) " → $fixed" else "",
|
||||
)
|
||||
consume
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
|
||||
// streaming — on a pad Android has no key layout for, the right stick and the triggers
|
||||
// are not on the axes their names suggest.
|
||||
val map = Gamepad.padMap(event.device)
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
axes["LT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
axes["RT"] = maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
axes["RX"] = event.getAxisValue(map.rightStickX)
|
||||
axes["RY"] = event.getAxisValue(map.rightStickY)
|
||||
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.leftTrigger))
|
||||
}
|
||||
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
)
|
||||
} else {
|
||||
map.level(event.getAxisValue(map.rightTrigger))
|
||||
}
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
if (observeInput) {
|
||||
activity?.padKeyProbe = keyProbe
|
||||
activity?.padMotionProbe = motionProbe
|
||||
}
|
||||
onDispose {
|
||||
activity?.let { a ->
|
||||
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
|
||||
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity?.pushPadProbes(probes)
|
||||
onDispose { activity?.removePadProbes(probes) }
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
|
||||
@@ -351,7 +244,7 @@ private fun ControllersBody(
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
heading()
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -825,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only when a correction is actually in force: on a pad Android has a key layout for
|
||||
// there is nothing to say, and a line that says "normal" on every device teaches
|
||||
// nobody anything. Named rather than merely flagged, so a field report can quote it.
|
||||
padButtonsNote(info.buttons)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -920,6 +823,12 @@ internal data class PadInfo(
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
/**
|
||||
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
|
||||
* the pad Android already knows, which is what a screenshot scene wants and what the note
|
||||
* under the card stays silent about.
|
||||
*/
|
||||
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
@@ -929,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
@@ -942,7 +852,8 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
dev.vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
|
||||
internal fun testRumble(dev: InputDevice) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
@@ -958,6 +869,20 @@ private fun testRumble(dev: InputDevice) {
|
||||
}
|
||||
|
||||
/** Identity line: VID:PID + the source classes Android assigned. */
|
||||
/**
|
||||
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
|
||||
* has no key layout for it — null for a pad it does know, which needs no explanation.
|
||||
*/
|
||||
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
|
||||
Gamepad.PadButtons.NATIVE -> null
|
||||
Gamepad.PadButtons.GENERIC_SONY ->
|
||||
"Android has no button layout for this controller — read as a PlayStation pad"
|
||||
Gamepad.PadButtons.GENERIC_XBOX ->
|
||||
"Android has no button layout for this controller — read as an Xbox pad"
|
||||
Gamepad.PadButtons.SONY_MODERN ->
|
||||
"Android has no button layout for this controller — face buttons corrected"
|
||||
}
|
||||
|
||||
private fun deviceDetail(dev: InputDevice): String =
|
||||
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
|
||||
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
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.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)
|
||||
|
||||
/**
|
||||
* A non-text row of the EDIT form — a switch or a stepped choice, driven like a settings row rather
|
||||
* than opening the keyboard. Add-host mode has none: they all edit properties a host only has once
|
||||
* it is saved.
|
||||
*/
|
||||
private class ExtraRow(
|
||||
val label: String,
|
||||
val value: String,
|
||||
/** Non-null = draw a [ConsoleSwitch] instead of the value text. */
|
||||
val toggled: Boolean?,
|
||||
val adjust: (Int) -> Unit,
|
||||
val activate: () -> Unit,
|
||||
)
|
||||
|
||||
@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,
|
||||
/**
|
||||
* The profile catalog, for the edit form's binding row. Empty (the default) simply omits that
|
||||
* row — which is also what a device with no profiles yet gets.
|
||||
*/
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
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(", ") ?: "") }
|
||||
// The two host properties the console could not reach at all until now. `copy` preserved them,
|
||||
// so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall
|
||||
// back to) could never decide either one, which the touch edit sheet has always offered.
|
||||
var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) }
|
||||
// Filtered through the live catalog, so a binding to a since-deleted profile reads as unset
|
||||
// rather than as a name nothing can resolve — the same guard the touch sheet applies.
|
||||
var boundId by remember(editHost, profiles) {
|
||||
mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } })
|
||||
}
|
||||
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),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
),
|
||||
)
|
||||
} 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"))
|
||||
}
|
||||
// The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit
|
||||
// properties a host only has once it has been saved.
|
||||
val extras = buildList {
|
||||
if (isEdit) {
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Shared clipboard",
|
||||
value = if (clipboard) "On" else "Off",
|
||||
toggled = clipboard,
|
||||
// Directional = state-targeted, so holding a direction can't oscillate — the
|
||||
// same rule the settings toggles and the pin picker use.
|
||||
adjust = { d -> clipboard = d > 0 },
|
||||
activate = { clipboard = !clipboard },
|
||||
),
|
||||
)
|
||||
if (profiles.isNotEmpty()) {
|
||||
// "Default settings" is the absence of a binding, not a profile, so it leads the
|
||||
// ring as a null rather than being faked as an entry in the catalog.
|
||||
val options = listOf<StreamProfile?>(null) + profiles
|
||||
val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0)
|
||||
fun stepTo(delta: Int) {
|
||||
val n = ((idx + delta) % options.size + options.size) % options.size
|
||||
boundId = options[n]?.id
|
||||
}
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Profile",
|
||||
value = options[idx]?.name ?: "Default settings",
|
||||
toggled = null,
|
||||
adjust = { d -> stepTo(d) },
|
||||
activate = { stepTo(1) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val actionIndex = fields.size + extras.size // the Save/Add action sits after everything
|
||||
|
||||
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
|
||||
}
|
||||
/** The focused row's extra, or null when the cursor is on a text field or the action. */
|
||||
fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size)
|
||||
fun activateField() {
|
||||
when {
|
||||
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
focus < fields.size -> openKeyboard(fields[focus].id)
|
||||
else -> focusedExtra()?.activate()
|
||||
}
|
||||
}
|
||||
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++
|
||||
// Left/right step the switch and the profile ring, exactly as they step a
|
||||
// settings row. On a text field or the action they still do nothing — there is
|
||||
// no value there to walk.
|
||||
NavDir.LEFT -> focusedExtra()?.adjust(-1)
|
||||
NavDir.RIGHT -> focusedExtra()?.adjust(1)
|
||||
}
|
||||
} 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().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp)
|
||||
.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) } }
|
||||
extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + 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().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
// Same 620 dp cap as the settings rows: a field stretched across a wide
|
||||
// landscape phone is a ribbon, not an input.
|
||||
Modifier.weight(1f).widthIn(max = 620.dp).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 = ink.fg(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) } }
|
||||
extras.forEachIndexed { i, e ->
|
||||
ExtraRowView(e, focused = focus == fields.size + i && editing == null) {
|
||||
onFieldClick(fields.size + 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)
|
||||
.consoleLegendInsets(landscape)
|
||||
.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,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onDismiss)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.consoleSafeArea()
|
||||
.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 = ink.fg)
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(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 ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
// The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on
|
||||
// `editing` shoved the whole value left the instant the keyboard opened — the same
|
||||
// layout-moves-under-focus bug the settings detail line had, one screen over.
|
||||
val caretAlpha by animateFloatAsState(
|
||||
if (editing) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "caret",
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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 = ink.fg)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
f.value.ifEmpty { f.placeholder },
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in
|
||||
* miniature — same glass, same end-aligned value slot, same `ConsoleSwitch` — because it IS a
|
||||
* settings row: it edits a stored property with left/right, and a user who has met one has met
|
||||
* both.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = "${row.label}, ${row.value}"
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (row.toggled != null) {
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text(
|
||||
row.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(if (focused) 1f else 0.6f),
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val labelColor by animateColorAsState(
|
||||
if (enabled) ink.accent else ink.fg(0.35f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "addLabel",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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 = labelColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 ink = LocalGamepadInk.current
|
||||
val shape = ConsoleShape.Keyboard
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
// Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on,
|
||||
// and a hardcoded white wash was the one surface a pale palette couldn't recolour.
|
||||
.background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f)))
|
||||
.border(1.dp, ink.fg(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) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
|
||||
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
|
||||
val bg by animateColorAsState(
|
||||
if (focused) ink.accent else ink.glass,
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
// `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the
|
||||
// unreadable combination, and the palette already resolved which way that goes.
|
||||
val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg")
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
.clip(ConsoleShape.Keycap)
|
||||
.background(bg)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = fg,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
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.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.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
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.LaunchedEffect
|
||||
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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
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,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// 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 body + BUTTONS scroll together — in a short
|
||||
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
|
||||
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
|
||||
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
|
||||
// the current action even when the stack scrolls.
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.consoleCard()
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scale by animateFloatAsState(
|
||||
if (focused) 1.02f else 1f,
|
||||
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "btnScale",
|
||||
)
|
||||
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
|
||||
// that's scrolled out of a short window, pull it into view (no-op when already visible).
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val focus by animateFloatAsState(
|
||||
if (focused) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnFocus",
|
||||
)
|
||||
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||
val bg by animateColorAsState(
|
||||
when {
|
||||
focused -> ink.accent
|
||||
primary -> ink.accent(0.20f)
|
||||
else -> ink.glass
|
||||
},
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> ink.fg(0.35f)
|
||||
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
|
||||
focused -> ink.onAccent
|
||||
primary -> ink.accent
|
||||
else -> ink.fg(0.85f)
|
||||
},
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnFg",
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
ink.fg(if (focused) 0.3f else 0.08f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnBorder",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
|
||||
.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) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Copy
|
||||
* link, 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,
|
||||
/**
|
||||
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
|
||||
* actions it says nothing about the host, it hands out the shortcut this very tile already is
|
||||
* (profile included), which is exactly what a pin is for.
|
||||
*/
|
||||
onCopyLink: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSpeedTest: (() -> Unit)? = null,
|
||||
/**
|
||||
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
|
||||
* shortcut, not a second host — offering the host's destructive actions on it would blur
|
||||
* exactly that, and the touch grid withholds them for the same reason.
|
||||
*/
|
||||
onUnpin: (() -> Unit)? = null,
|
||||
profileName: String? = null,
|
||||
) {
|
||||
GamepadDialog(
|
||||
title = if (profileName != null) "$hostName · $profileName" else hostName,
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (onUnpin != null) {
|
||||
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
|
||||
add(DialogAction("Copy link", onClick = onCopyLink))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
return@buildList
|
||||
}
|
||||
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
|
||||
add(DialogAction("Copy link", onClick = onCopyLink))
|
||||
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||
add(DialogAction("Forget", onClick = onForget))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText(
|
||||
if (onUnpin != null) {
|
||||
"This card is a shortcut to this host with one profile. Unpinning it changes " +
|
||||
"nothing about the host or the profile."
|
||||
} else {
|
||||
"Manage this saved host."
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.consoleCard()
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ink = LocalGamepadInk.current
|
||||
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 context = LocalContext.current
|
||||
val name = remember(context) { deviceName(context) }
|
||||
|
||||
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 {
|
||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
ConsoleModal {
|
||||
Column(
|
||||
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||
.consoleCard()
|
||||
.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 = ink.fg)
|
||||
Text(
|
||||
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = ink.fg(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 = ink.danger, 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 ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Box(
|
||||
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||
.background(if (focused) ink.accent(0.20f) else ink.glass)
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
value.toString(),
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
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.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.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.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.TransformOrigin
|
||||
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 kotlin.math.cos
|
||||
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.
|
||||
|
||||
/**
|
||||
* How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as
|
||||
* a rotation — see the projection note at the call site.
|
||||
*/
|
||||
private const val CARD_TURN_RAD = 0.838f
|
||||
|
||||
/** 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)
|
||||
/**
|
||||
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
|
||||
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
|
||||
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those —
|
||||
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
|
||||
* its own shelf and every launch off it carries this profile.
|
||||
*/
|
||||
val pinnedProfileId: String? = null,
|
||||
/**
|
||||
* The profile a press will actually connect with — the host's binding, or the pin's own
|
||||
* profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card
|
||||
* the profile is the entire reason the card exists, and a card that only whispers it in grey
|
||||
* body text can't say that. Matches the Apple client's tile.
|
||||
*/
|
||||
val profileName: String? = null,
|
||||
/** The profile's `#RRGGBB` chip colour, if it set one. */
|
||||
val profileAccent: Color? = null,
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple), pinned cards included — 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)
|
||||
|
||||
// Bumped on every confirm — the centred card dips under the press and springs back, so A reads
|
||||
// as a button being pushed rather than as a screen simply changing.
|
||||
var pressToken by remember { mutableIntStateOf(0) }
|
||||
val press = remember { Animatable(1f) }
|
||||
LaunchedEffect(pressToken) {
|
||||
if (pressToken == 0) return@LaunchedEffect
|
||||
press.animateTo(0.97f, ConsoleMotion.ease(70))
|
||||
press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
},
|
||||
// A / D-pad-center → Connect
|
||||
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
|
||||
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 = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||
val arrowTint = PadGlyph.Arrow
|
||||
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)
|
||||
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
|
||||
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
|
||||
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
|
||||
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
|
||||
// legend keep their insets; they are content.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
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.
|
||||
// Signed, because which SIDE a card fans to decides which edge it turns on.
|
||||
val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
val offset = signed.absoluteValue.coerceIn(0f, 1f)
|
||||
GamepadHostTile(
|
||||
tile = tile,
|
||||
centred = offset < 0.5f,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
// The press dip applies to the CENTRED card only — it is the one
|
||||
// the button acted on, and a whole carousel flinching would read
|
||||
// as the screen moving rather than a card being pressed.
|
||||
val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset)
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
alpha = lerp(1f, 0.5f, offset)
|
||||
}
|
||||
.graphicsLayer {
|
||||
// The neighbours TURN away, projected rather than rendered in 3D.
|
||||
// `cos(angle)` as a horizontal squeeze IS the orthographic
|
||||
// projection of a Y-axis rotation, and hinging it on the edge the
|
||||
// card fans from is what carries the direction the rotation's sign
|
||||
// would have. The Apple client arrived here the hard way (see
|
||||
// GamepadCarousel.swift): a real `rotation3DEffect` renders the
|
||||
// card through an offscreen pass and flashed as the strip settled.
|
||||
// Affine transforms don't.
|
||||
scaleX = cos(CARD_TURN_RAD * offset)
|
||||
transformOrigin =
|
||||
TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f)
|
||||
}
|
||||
// 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) {
|
||||
pressToken++
|
||||
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().consoleSafeArea()
|
||||
.padding(end = ConsoleEdgeInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// The TITLE has priority (unweighted, so it is measured at its full width first) and the
|
||||
// chip takes what is left, ellipsizing its device name. The other way round — which is
|
||||
// what a weighted header gave — a talkative controller name ("Xbox Wireless Controller")
|
||||
// ate a 360 dp portrait phone's title down to "Selec…".
|
||||
ConsoleHeader("Select a Host")
|
||||
if (controllerName != null) {
|
||||
ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||
// it ignores the system bars (the nav-bar inset made the bottom gap look oversized) but never
|
||||
// the cutout — reverse-landscape parks the punch on this very corner.
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints, hazeState = hazeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One glass landscape console tile — bigger and bolder than the touch grid's HostCard, and cut from
|
||||
* the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the
|
||||
* light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which
|
||||
* earns the lift and the accent bloom.
|
||||
*/
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = centred)
|
||||
// A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral
|
||||
// glass, so "already yours" reads before you get to the label.
|
||||
val fill = if (tile.filled) ink.accent(0.20f) else ink.glass
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
// The carousel already drives its own scale; the glass must not fight it with a second.
|
||||
.consoleGlass(
|
||||
ConsoleShape.Tile,
|
||||
ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus),
|
||||
// A DASHED edge on anything not yet saved — a host found on the network, and the
|
||||
// Add tile. It is the touch grid's own convention and the Apple client's, and it
|
||||
// says "not yours yet" before the subtitle has to.
|
||||
dashed = !tile.filled,
|
||||
)
|
||||
.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 = ink.fg(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 = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (tile.profileName != null) {
|
||||
ConsoleProfileChip(
|
||||
name = tile.profileName,
|
||||
accent = tile.profileAccent,
|
||||
// On a PIN card the profile is why the card exists; on a bound host's own card it
|
||||
// is a note about what a press will do. Same chip, two weights.
|
||||
prominent = tile.pinnedProfileId != null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch
|
||||
* grid's own chip (`HostComponents.kt`) — same shape and the same quiet/prominent split, but inked
|
||||
* from the console palette rather than `MaterialTheme`, since it sits on the aurora.
|
||||
*
|
||||
* A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a
|
||||
* moss or copper field the brand violet would be the one foreign colour on the card.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConsoleProfileChip(
|
||||
name: String,
|
||||
accent: Color?,
|
||||
prominent: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val tint = accent ?: ink.accent
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(ConsoleShape.Pill)
|
||||
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
|
||||
.padding(horizontal = 9.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
name,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold,
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
// Lit from the top like every other console surface — and the unsaved badge takes the palette's
|
||||
// own accent at low opacity rather than the brand violet, which on a copper or moss field was
|
||||
// the one square of the wrong hue on the screen.
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
|
||||
}
|
||||
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 = ink.fg,
|
||||
)
|
||||
tile.isAdd -> Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = if (tile.filled) ink.fg else ink.accent,
|
||||
)
|
||||
else -> Text(
|
||||
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (tile.filled) ink.fg else ink.accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -82,8 +83,9 @@ fun GamepadNavEffect(
|
||||
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.
|
||||
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
|
||||
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
|
||||
// screen's, and never the console shell's underneath.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
@@ -95,7 +97,7 @@ fun GamepadNavEffect(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
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.
|
||||
@@ -113,13 +115,10 @@ fun GamepadNavEffect(
|
||||
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity.pushPadProbes(it) }
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
probes?.let { activity.removePadProbes(it) }
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
@@ -186,9 +185,11 @@ fun GamepadNavEffect2D(
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
// One entry on the MainActivity probe stack, removed by identity on dispose — 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.
|
||||
// teardown must take only its own claim. On the console this effect sits OVER the Skia
|
||||
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
|
||||
// the moment this screen pops, instead of dying with a nulled slot.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
when (Gamepad.padKeyCode(ev)) {
|
||||
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 }
|
||||
@@ -220,13 +221,10 @@ fun GamepadNavEffect2D(
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity.pushPadProbes(it) }
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
probes?.let { activity.removePadProbes(it) }
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
|
||||
/**
|
||||
* The console home's tiles, in carousel order: every saved host with its pinned host+profile cards
|
||||
* immediately behind it, then the hosts seen on the network but not yet saved, then Add Host.
|
||||
*
|
||||
* Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and
|
||||
* what a tile claims — a pin drifting away from the host it belongs to, a discovered host offered a
|
||||
* second time next to the saved record it already is, a chip naming a profile the press won't
|
||||
* actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without
|
||||
* one; the console home itself needs the live JNI core to compose at all.
|
||||
*
|
||||
* [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile
|
||||
* store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed
|
||||
* Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list
|
||||
* builder.
|
||||
*/
|
||||
internal fun buildHomeTiles(
|
||||
savedHosts: List<KnownHost>,
|
||||
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
|
||||
profiles: List<StreamProfile>,
|
||||
pinsFor: (KnownHost) -> List<StreamProfile>,
|
||||
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
|
||||
discoveredUnsaved: List<DiscoveredHost>,
|
||||
isOnline: (KnownHost) -> Boolean,
|
||||
/**
|
||||
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a
|
||||
* host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile.
|
||||
*/
|
||||
onConnect: (KnownHost, String?) -> Unit,
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
): List<HomeTile> = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
// The binding is what a press will actually do, so the tile says so — the console
|
||||
// can't edit profiles, but it must never lie about which one it uses. It rides in
|
||||
// the card's own chip now rather than as a "· Name" tail on the address, which is
|
||||
// where it read as an afterthought.
|
||||
profileName = bound?.name,
|
||||
profileAccent = accentColor(bound?.accent),
|
||||
activate = { onConnect(kh, null) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press each,
|
||||
// which is the affordance a controller surface does well (menus are not).
|
||||
pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
// The address, like every other card — the PROFILE is what makes this card
|
||||
// different, and it now says so in the chip instead of standing in for the
|
||||
// subtitle, which left a pin card unable to say where it pointed.
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
profileName = p.name,
|
||||
profileAccent = accentColor(p.accent),
|
||||
activate = { onConnect(kh, p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { onConnectDiscovered(dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = onAddHost,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,16 @@ suspend fun connectToHost(
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
// The requested audio format as the two Hello fields — `0`/`0` when the user chose Standard,
|
||||
// which is what keeps the lossless capability bit OFF (see `audioFormatWire`).
|
||||
//
|
||||
// Sent at every channel count, including surround. This used to be clamped to Opus on 5.1/7.1
|
||||
// because a lossless surround frame did not fit one QUIC datagram, but the frame ladder is
|
||||
// channel-aware: a 5.1 session simply negotiates a shorter frame (and pays for it in packet
|
||||
// rate) and 96/24 5.1 fits nothing and is declined. That is the host's decision to make with the
|
||||
// connection's real datagram size in hand, not one to pre-empt from here with an MTU this side
|
||||
// never measured.
|
||||
val (audioRateHz, audioBits) = settings.audioFormatWire()
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
|
||||
// sockets) — must be applied before connect, since sockets are tagged at creation.
|
||||
@@ -75,6 +85,11 @@ suspend fun connectToHost(
|
||||
hdrEnabled, multiSlice,
|
||||
frameParts,
|
||||
settings.audioChannels,
|
||||
// The audio format this session asks for. Only ever a request: the host's own gate
|
||||
// may resolve it back to Opus, and the native side downgrades it first if AAudio on
|
||||
// this device will not open the rate — a rate the wire has committed to cannot be
|
||||
// rescued afterwards, so the fallback has to happen before the Hello.
|
||||
audioRateHz, audioBits,
|
||||
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
|
||||
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
|
||||
// host resolves the emitted codec from both.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
|
||||
// Where the player was in a host's library, so the round trip back from a stream doesn't lose it.
|
||||
// The Android mirror of the Apple client's `LibraryScrollMemory`.
|
||||
//
|
||||
// Leaving a stream re-composes the library screen from scratch — new `remember`s, a new
|
||||
// `LazyGridState`, a new `PagerState` — so a library of any size came back at the top every time.
|
||||
// For the loop this screen exists to serve (browse → play → quit → browse), that means
|
||||
// re-scrolling to the same place on every lap.
|
||||
//
|
||||
// The position is remembered as the ID OF THE TITLE the player last opened, not as a scroll offset
|
||||
// or an index. An offset is meaningless across the things that legitimately change between visits —
|
||||
// a rotation, a window resize, a foldable unfolding, a host that gained or lost titles, or the
|
||||
// running-first ordering this screen now applies. A title id survives all of them, and the grid
|
||||
// turns it back into a position at whatever the current layout is.
|
||||
|
||||
/**
|
||||
* Per-host "last title opened", in `SharedPreferences`.
|
||||
*
|
||||
* Small, non-sensitive and worth surviving a process death — the app being killed in the background
|
||||
* while a stream is up is exactly when this is most useful — so preferences rather than an in-memory
|
||||
* cache. One key per host record id, namespaced so nothing else can collide with it.
|
||||
*/
|
||||
object LibraryPosition {
|
||||
private const val PREFS = "punktfunk_library_position"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** The title last opened from this host's library, if any is remembered. */
|
||||
fun last(context: Context, hostId: String): String? =
|
||||
prefs(context).getString(hostId, null)
|
||||
|
||||
/**
|
||||
* Remember a title as this host's position. Called when one is LAUNCHED, which is the only
|
||||
* moment the player is definitely leaving the grid for it — remembering on mere focus would
|
||||
* make a scroll past a tile into a decision.
|
||||
*/
|
||||
fun remember(context: Context, hostId: String, gameId: String) {
|
||||
prefs(context).edit().putString(hostId, gameId).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a host's position — part of removing the host, so a forgotten host leaves no trace of
|
||||
* what somebody was playing behind on the device.
|
||||
*/
|
||||
fun forget(context: Context, hostId: String) {
|
||||
prefs(context).edit().remove(hostId).apply()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
// The clipboard half of "Copy link" (design/client-deep-links.md §4/§5), shared by every surface
|
||||
// that hands a `punktfunk://` URL out: a host card, a pinned card, and a library title. The URL
|
||||
// each one builds is its own business; whether the platform TOOK it, and what to say about that,
|
||||
// is the same answer three times over — and getting it wrong in one place is how a menu item ends
|
||||
// up silently doing nothing on exactly one screen.
|
||||
|
||||
/** Put a `punktfunk://` URL on the clipboard. False = no clipboard service, or it refused. */
|
||||
internal fun putLinkOnClipboard(context: Context, url: String): Boolean {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
?: return false
|
||||
return runCatching {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
|
||||
}.isSuccess
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell the user about a copy, or null for "say nothing".
|
||||
*
|
||||
* Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is the
|
||||
* platform's own documented anti-pattern. Below it nothing visible happens at all unless we say
|
||||
* so — a silent menu item reads as a broken one.
|
||||
*/
|
||||
internal fun linkCopyMessage(copied: Boolean): String? = when {
|
||||
copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> null
|
||||
copied -> "Link copied."
|
||||
else -> "Couldn't copy the link to the clipboard."
|
||||
}
|
||||
@@ -109,12 +109,29 @@ class MainActivity : ComponentActivity() {
|
||||
var gamepadRouter: GamepadRouter? = null
|
||||
|
||||
/**
|
||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||
* Called for every key/motion event while not streaming; a `true` return consumes the event —
|
||||
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
|
||||
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
|
||||
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
|
||||
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
|
||||
* opens over itself, and the Controllers screen's input test.
|
||||
*/
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
|
||||
|
||||
/**
|
||||
* The pad-probe claims, a STACK — only the top entry sees events. A single last-writer-wins
|
||||
* slot is how the console shell used to lose the pad for good: a screen composed over it
|
||||
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell —
|
||||
* whose install effect had no reason to re-run — never got it back. Pushing on install and
|
||||
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
|
||||
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
|
||||
* its own entry, and whatever is left on top resumes seeing the pad.
|
||||
*/
|
||||
private val padProbes = mutableListOf<PadProbes>()
|
||||
|
||||
fun pushPadProbes(p: PadProbes) { padProbes += p }
|
||||
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
|
||||
|
||||
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
|
||||
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
|
||||
|
||||
/**
|
||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
||||
@@ -599,7 +616,7 @@ class MainActivity : ComponentActivity() {
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -691,8 +708,10 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// already move focus on their own, so they fall through to super untouched. Read
|
||||
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
|
||||
// menus on the right buttons too, not only the stream.
|
||||
when (Gamepad.padKeyCode(event)) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
|
||||
@@ -37,6 +37,12 @@ data class SettingsOverlay(
|
||||
val hdrEnabled: Boolean? = null,
|
||||
val compositor: Int? = null,
|
||||
val audioChannels: Int? = null,
|
||||
/**
|
||||
* The requested audio format ([AUDIO_FORMAT_OPTIONS]'s stored value). Profileable because it
|
||||
* is about how a HOST is streamed — a wired desktop can afford lossless, a phone on a hotspot
|
||||
* cannot — rather than about this device's hardware.
|
||||
*/
|
||||
val audioFormat: String? = null,
|
||||
val micEnabled: Boolean? = null,
|
||||
val echoCancel: Boolean? = null,
|
||||
val touchMode: TouchMode? = null,
|
||||
@@ -73,6 +79,7 @@ data class SettingsOverlay(
|
||||
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
|
||||
compositor = compositor ?: base.compositor,
|
||||
audioChannels = audioChannels ?: base.audioChannels,
|
||||
audioFormat = audioFormat ?: base.audioFormat,
|
||||
micEnabled = micEnabled ?: base.micEnabled,
|
||||
echoCancel = echoCancel ?: base.echoCancel,
|
||||
touchMode = touchMode ?: base.touchMode,
|
||||
@@ -110,6 +117,7 @@ data class SettingsOverlay(
|
||||
hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled,
|
||||
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
|
||||
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
|
||||
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
|
||||
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
|
||||
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
|
||||
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
|
||||
@@ -141,6 +149,7 @@ data class SettingsOverlay(
|
||||
"hdr_enabled" -> copy(hdrEnabled = null)
|
||||
"compositor" -> copy(compositor = null)
|
||||
"audio_channels" -> copy(audioChannels = null)
|
||||
"audio_format" -> copy(audioFormat = null)
|
||||
"mic_enabled" -> copy(micEnabled = null)
|
||||
"echo_cancel" -> copy(echoCancel = null)
|
||||
"touch_mode" -> copy(touchMode = null)
|
||||
@@ -167,6 +176,7 @@ data class SettingsOverlay(
|
||||
if (hdrEnabled != null) add("hdr_enabled")
|
||||
if (compositor != null) add("compositor")
|
||||
if (audioChannels != null) add("audio_channels")
|
||||
if (audioFormat != null) add("audio_format")
|
||||
if (micEnabled != null) add("mic_enabled")
|
||||
if (echoCancel != null) add("echo_cancel")
|
||||
if (touchMode != null) add("touch_mode")
|
||||
@@ -201,6 +211,7 @@ data class SettingsOverlay(
|
||||
hdrEnabled?.let { j.put("hdr_enabled", it) }
|
||||
compositor?.let { j.put("compositor", it) }
|
||||
audioChannels?.let { j.put("audio_channels", it) }
|
||||
audioFormat?.let { j.put("audio_format", it) }
|
||||
micEnabled?.let { j.put("mic_enabled", it) }
|
||||
echoCancel?.let { j.put("echo_cancel", it) }
|
||||
touchMode?.let { j.put("touch_mode", it.name) }
|
||||
@@ -224,7 +235,7 @@ data class SettingsOverlay(
|
||||
/** Keys this build models; everything else in a stored overlay is carried through. */
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"hdr_enabled", "compositor", "audio_channels", "audio_format", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
@@ -241,6 +252,7 @@ data class SettingsOverlay(
|
||||
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
|
||||
compositor = j.optIntOrNull("compositor"),
|
||||
audioChannels = j.optIntOrNull("audio_channels"),
|
||||
audioFormat = j.optStringOrNull("audio_format"),
|
||||
micEnabled = j.optBooleanOrNull("mic_enabled"),
|
||||
echoCancel = j.optBooleanOrNull("echo_cancel"),
|
||||
touchMode = j.optStringOrNull("touch_mode")
|
||||
|
||||
@@ -62,6 +62,21 @@ data class Settings(
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
/**
|
||||
* Requested audio format — the cross-client `audio_format` key: [AUDIO_FORMAT_OPUS] (the
|
||||
* default, and byte-for-byte the session every build before the lossless plane ran) or one of
|
||||
* the lossless rows in [AUDIO_FORMAT_OPTIONS], which span both rate families.
|
||||
*
|
||||
* Off by default and deliberately: lossless takes 2.1–8.5 Mbps off the top of the link,
|
||||
* OUTSIDE the ABR loop that manages the video budget, against the ~256 kbps Opus it replaces —
|
||||
* so a user has to pick it. Since 2026-08-17 this setting is the ONLY opt-in: the host's half
|
||||
* (`PUNKTFUNK_AUDIO_HIRES`) defaults ON and is an opt-OUT (`=0`), so this choice is enough on
|
||||
* any host that has not deliberately turned the plane off.
|
||||
* A REQUEST, never a fact: the host runs its gate and may answer Opus anyway, and
|
||||
* the native side downgrades the rate first if THIS device will not open it. What actually
|
||||
* happened is on the stats HUD, and in logcat's `audio: plane codec=… rate=…` line.
|
||||
*/
|
||||
val audioFormat: String = AUDIO_FORMAT_OPUS,
|
||||
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
||||
* preference — the host emits it when it can, else falls back. AMediaCodec decodes whichever
|
||||
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
|
||||
@@ -295,6 +310,7 @@ class SettingsStore(context: Context) {
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
audioFormat = prefs.getString(K_AUDIO_FORMAT, AUDIO_FORMAT_OPUS) ?: AUDIO_FORMAT_OPUS,
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
|
||||
@@ -350,6 +366,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_AUDIO_FORMAT, s.audioFormat)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
|
||||
@@ -387,6 +404,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_AUDIO_FORMAT = "audio_format"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_ECHO_CANCEL = "echo_cancel"
|
||||
@@ -691,6 +709,123 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
|
||||
8 to "7.1 Surround",
|
||||
)
|
||||
|
||||
/** Opus 48 kHz — the default, and byte-for-byte the session every earlier build ran. */
|
||||
const val AUDIO_FORMAT_OPUS = "opus"
|
||||
|
||||
/**
|
||||
* Bit-exact PCM at 44.1 kHz / 24-bit (~2.1 Mbps). The CD family's base rate: what an ordinary
|
||||
* Windows endpoint or a 44.1 kHz interface reports as its own engine rate, and the request that
|
||||
* spares such a host a resample it would otherwise do on the way out.
|
||||
*/
|
||||
const val AUDIO_FORMAT_LOSSLESS_441 = "lossless441"
|
||||
|
||||
/**
|
||||
* Bit-exact PCM at 48 kHz / 24-bit (~2.3 Mbps). The honest win even without a hi-res interface:
|
||||
* no lossy stage at all, and no double resample on a host whose engine already runs at 48 kHz.
|
||||
*/
|
||||
const val AUDIO_FORMAT_LOSSLESS_48 = "lossless48"
|
||||
|
||||
/** Bit-exact PCM at 88.2 kHz / 24-bit (~4.2 Mbps) — 96 kHz's counterpart in the 44.1 family. */
|
||||
const val AUDIO_FORMAT_LOSSLESS_882 = "lossless882"
|
||||
|
||||
/**
|
||||
* Bit-exact PCM at 96 kHz / 24-bit (~4.6 Mbps), and only real if the host's capture endpoint
|
||||
* genuinely runs at 96 kHz — the host declines rather than upsampling to meet the request.
|
||||
*/
|
||||
const val AUDIO_FORMAT_LOSSLESS_96 = "lossless96"
|
||||
|
||||
/**
|
||||
* Bit-exact PCM at 176.4 kHz / 24-bit — **8.5 Mbps**, and the one row far more likely to be
|
||||
* declined than granted. Three separate things have to go right: the host's bandwidth gate gives
|
||||
* audio at most a quarter of the video budget, so the session needs ~34 Mbps of video before it
|
||||
* will even consider it; a stereo frame only fits a QUIC datagram on the ladder's shortest rung
|
||||
* (1 ms — a thousand datagrams a second — at ~1 069 B, so the first connection with a smaller
|
||||
* datagram declines it), and a surround one fits no rung at all; and very few Android outputs will
|
||||
* open the rate, which the native probe settles before the handshake. Offered because it is
|
||||
* reachable, not because it is likely — the HUD's `audio lossless …` line is what says which
|
||||
* happened.
|
||||
*/
|
||||
const val AUDIO_FORMAT_LOSSLESS_1764 = "lossless1764"
|
||||
|
||||
/**
|
||||
* (stored value, label) for the requested audio format — the cross-client table, matching the
|
||||
* Apple client's `AudioFormatChoice` raw values and the desktop `AUDIO_FORMATS` so a profile
|
||||
* written on any of them is honoured on the others.
|
||||
*
|
||||
* ⚠ **The stored values are shared VERBATIM and must never be renamed.** A profile carries the key
|
||||
* through untouched, so a spelling that differs by one character fails in the worst possible way:
|
||||
* the profile keeps "working" on the other client and silently inherits its global default
|
||||
* instead. The naming rule is the kHz figure with the decimal point dropped — `lossless48`,
|
||||
* `lossless96`, and for the 44.1 family `lossless441` / `lossless882` / `lossless1764`.
|
||||
*
|
||||
* **Both rate families are here now.** They were not: every buffer figure in the shared jitter
|
||||
* policy used to be `ms × perMs` with `perMs` an INTEGER number of samples per millisecond, which
|
||||
* made 44 100 → 44.1 truncate to 44 — a silent 2.3 % error in every target, every de-prime fuse
|
||||
* and every reported buffer depth, and the whole reason the 44.1 family was deferred rather than
|
||||
* refused (design/hi-res-audio.md §4.1). Core now multiplies before it divides, which is exact at
|
||||
* every rate, so the deferral is lifted.
|
||||
*
|
||||
* A row being offered is not a promise it can be delivered: the host's gate, this device's own
|
||||
* output, and the path MTU each get a veto, and the ones at the top of the list get vetoed often.
|
||||
* What actually happened is on the HUD.
|
||||
*
|
||||
* Lossless at **16**-bit is deliberately absent at every rate: it spends ~1.4–1.5 Mbps to sound
|
||||
* like the transparent 256 kbps Opus it replaces, and it is the one lossless request whose wire
|
||||
* parameters are indistinguishable from a legacy one. 24-bit is where the plane earns its
|
||||
* bandwidth.
|
||||
*/
|
||||
val AUDIO_FORMAT_OPTIONS = listOf(
|
||||
AUDIO_FORMAT_OPUS to "Standard (Opus)",
|
||||
AUDIO_FORMAT_LOSSLESS_441 to "Lossless 44.1 kHz / 24-bit",
|
||||
AUDIO_FORMAT_LOSSLESS_48 to "Lossless 48 kHz / 24-bit",
|
||||
AUDIO_FORMAT_LOSSLESS_882 to "Lossless 88.2 kHz / 24-bit",
|
||||
AUDIO_FORMAT_LOSSLESS_96 to "Lossless 96 kHz / 24-bit",
|
||||
AUDIO_FORMAT_LOSSLESS_1764 to "Lossless 176.4 kHz / 24-bit",
|
||||
)
|
||||
|
||||
/**
|
||||
* The `(rateHz, bits)` pair [audioFormat] asks the host for, in `nativeConnect`'s terms.
|
||||
*
|
||||
* ⚠⚠ **Opus is `0`/`0`, the "did not ask" sentinel — NOT `48000`/`16`.** Core sets
|
||||
* `CLIENT_CAP_AUDIO_HIRES` when either field is non-zero, because it keys on *a format was
|
||||
* specified* rather than *the format differs from the default*: 48 kHz/16-bit is the cheapest
|
||||
* lossless rung as well as the legacy pair, so the other rule would make it the one rung nobody
|
||||
* could ask for. Sending `48000`/`16` for a user who chose Standard therefore advertises the
|
||||
* capability, and the host then hands that user 1.5 Mbps of lossless PCM instead of 256 kbps of
|
||||
* Opus. This returned that pair until all four clients were compared.
|
||||
*
|
||||
* ⚠⚠ **That bug got worse on 2026-08-17, when the host's `PUNKTFUNK_AUDIO_HIRES` gate went
|
||||
* default-ON.** It used to need a host whose operator had opted in — rare, so a slip here would
|
||||
* have been survivable and probably unnoticed. The blast radius is now every host that has not
|
||||
* deliberately opted out, i.e. all of them. The zeroes below are load-bearing.
|
||||
*
|
||||
* The zeroes are also what keeps a default `Hello` byte-identical to a pre-lossless one — the wire
|
||||
* encodes an explicit 48 000/16 the same as absent, and the whole difference is the capability bit.
|
||||
*
|
||||
* Deriving the pair FROM the stored format is what stops the two ever disagreeing. An unrecognized
|
||||
* stored value — a newer build's, or a corrupted pref — resolves to Opus rather than blocking the
|
||||
* connect.
|
||||
*
|
||||
* The rate this returns is only the REQUEST. The native side runs it down a fallback ladder first
|
||||
* (`session::connect::rate_fallback_ladder`), because AAudio grants an explicitly-asked rate or
|
||||
* fails the open and never substitutes — so a rate this device cannot play must never reach the
|
||||
* wire.
|
||||
*/
|
||||
fun Settings.audioFormatWire(): Pair<Int, Int> = when (audioFormat) {
|
||||
AUDIO_FORMAT_LOSSLESS_441 -> 44_100 to 24
|
||||
AUDIO_FORMAT_LOSSLESS_48 -> 48_000 to 24
|
||||
AUDIO_FORMAT_LOSSLESS_882 -> 88_200 to 24
|
||||
AUDIO_FORMAT_LOSSLESS_96 -> 96_000 to 24
|
||||
AUDIO_FORMAT_LOSSLESS_1764 -> 176_400 to 24
|
||||
else -> AUDIO_FORMAT_WIRE_UNSPECIFIED
|
||||
}
|
||||
|
||||
/**
|
||||
* The `(rateHz, bits)` that mean "this session is not asking for the lossless plane" — see
|
||||
* [audioFormatWire] for why it is a pair of zeroes rather than the legacy 48 000/16.
|
||||
*/
|
||||
val AUDIO_FORMAT_WIRE_UNSPECIFIED = 0 to 0
|
||||
|
||||
/**
|
||||
* (stored value, label) for the preferred video codec — the cross-client table (the Rust
|
||||
* `CODECS`), so a value another client or a profile stored is always representable here.
|
||||
|
||||
@@ -824,6 +824,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
field = "audio_channels",
|
||||
caption = "Requested from the host; it downmixes if it has fewer.",
|
||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||
// Offered at every channel count. It used to be hidden on 5.1/7.1, because a lossless
|
||||
// surround frame did not fit one QUIC datagram at the default MTU — but the frame ladder is
|
||||
// channel-aware, so a surround session negotiates a shorter frame instead of being refused,
|
||||
// and only the top of this list genuinely fits nothing. Which rows a given session can
|
||||
// actually have depends on the host, this device's output and the path MTU, none of which
|
||||
// this screen knows; the HUD's `audio lossless …` line is what reports the answer.
|
||||
SettingDropdown(
|
||||
label = "Audio format",
|
||||
options = AUDIO_FORMAT_OPTIONS,
|
||||
selected = s.audioFormat,
|
||||
field = "audio_format",
|
||||
caption = "Lossless sends uncompressed audio on top of the video — 2.3 Mbps at " +
|
||||
"48 kHz, 4.6 at 96, 8.5 at 176.4 — and the top rates are often declined, " +
|
||||
"surround especially. The host has its own switch and both must be on; " +
|
||||
"otherwise the session stays on Opus, which is already effectively " +
|
||||
"transparent. The overlay shows what a session actually got.",
|
||||
) { f -> update(s.copy(audioFormat = f)) }
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Feeds this device's microphone to the host",
|
||||
@@ -945,9 +962,14 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
// The one row here that is OFF by default (see Settings.padSpeaker for why), which
|
||||
// makes a silent pad speaker look exactly like broken hardware — the failure this
|
||||
// subtitle exists to pre-empt, after it cost a full evening of host-side measuring.
|
||||
// Say the default out loud rather than describing only what "on" does.
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker — " +
|
||||
"off by default, so the pad's speaker stays silent until you turn this on",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
|
||||
@@ -18,13 +18,13 @@ import kotlin.math.roundToInt
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 35-double
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 38-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
|
||||
* audioAvOffsetMs]`. Every read
|
||||
* audioAvOffsetMs, audioCodec, audioRateHz, audioBits]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
|
||||
@@ -46,6 +46,10 @@ import kotlin.math.roundToInt
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
|
||||
*
|
||||
* The RESOLVED audio format (35–37) is the one figure that is not reserved for
|
||||
* [StatsVerbosity.DETAILED] — it renders from [StatsVerbosity.NORMAL] up, and only on a lossless
|
||||
* session. See [audioFormatLine]. (Not on COMPACT, which is one line by definition.)
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@@ -182,6 +186,15 @@ internal fun StatsOverlay(
|
||||
if (detailed) {
|
||||
audioLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
// NOT gated to the detailed tier, unlike the audio latency above it, and deliberately: it
|
||||
// is the one thing a user who turned lossless on needs to see. The failure it guards
|
||||
// against (design/hi-res-audio.md §4.3, §10) is a session that costs 2.1–8.5 Mbps and
|
||||
// delivers ordinary Opus, which is indistinguishable from success without a surface naming
|
||||
// what the HOST resolved. `null` on the Opus plane every ordinary session runs, so the
|
||||
// common case gains no line at all — and the top of the format menu is declined often
|
||||
// enough (176.4 kHz fits only the ladder's shortest 1 ms rung, and hi-res surround fits no
|
||||
// rung at all) that "the setting says one thing" is not evidence of anything.
|
||||
audioFormatLine(s)?.let { statLine(it, Color(0xFFB0FFD0)) }
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
@@ -215,6 +228,50 @@ private fun audioLine(s: DoubleArray): String? {
|
||||
return "audio buffer $bufferMs ms$avTerm"
|
||||
}
|
||||
|
||||
/**
|
||||
* The RESOLVED audio format from 35–37 — `audio lossless 96 kHz / 24-bit` — or `null` on the Opus
|
||||
* plane and on an older native layout.
|
||||
*
|
||||
* Deliberately silent for Opus rather than printing `audio opus 48 kHz`: that is what every
|
||||
* session has always been, so a line stating it would be noise on the HUD of every user who never
|
||||
* touched the setting. The line exists for the opposite case, and it is the only surface that can
|
||||
* answer it: the format the SETTINGS screen shows is what this device REQUESTED, and the host's
|
||||
* gate can decline every one of them (its own switch is off by default) — leaving a session that
|
||||
* looks, sounds and measures exactly like a granted one. The native side can also have downgraded
|
||||
* the request before the handshake, if this device's output would not open the rate. Both land
|
||||
* here as the truth.
|
||||
*
|
||||
* `codec` is the wire byte: 0 = Opus on `0xC9`, 2 = lossless PCM on `0xD3` (1 is reserved for a
|
||||
* FLAC that was measured and not taken).
|
||||
*
|
||||
* The rate is rendered in kHz to one decimal when it needs one, because half the ladder does: the
|
||||
* 44.1 kHz family (44 100 / 88 200 / 176 400) does not divide by a thousand, and printing raw Hz
|
||||
* for it — as this did while the ladder was 48/96 only — put the settings menu's "44.1 kHz" next
|
||||
* to a HUD saying "44100 Hz" and left the reader to decide whether those were the same session.
|
||||
* The whole point of this line is that it is comparable at a glance with what was asked for.
|
||||
*
|
||||
* Built by integer division rather than `"%.1f".format(…)` deliberately: that formatter renders
|
||||
* through the default locale and would say "44,1 kHz" on a device set to most of Europe — a
|
||||
* decimal comma where the settings row it is meant to be compared against has a point. Every rate
|
||||
* this plane carries is a whole number of hundreds of Hz, so the tenths digit is exact.
|
||||
*/
|
||||
private fun audioFormatLine(s: DoubleArray): String? {
|
||||
if (s.size < 38) return null
|
||||
if (s[35].roundToInt() != AUDIO_CODEC_PCM_WIRE) return null
|
||||
val rateHz = s[36].roundToInt()
|
||||
val bits = s[37].roundToInt()
|
||||
if (rateHz <= 0 || bits <= 0) return null
|
||||
val khz = if (rateHz % 1000 == 0) {
|
||||
"${rateHz / 1000} kHz"
|
||||
} else {
|
||||
"${rateHz / 1000}.${rateHz % 1000 / 100} kHz"
|
||||
}
|
||||
return "audio lossless $khz / $bits-bit"
|
||||
}
|
||||
|
||||
/** `quic::AUDIO_CODEC_PCM` — the `0xD3` lossless plane's wire byte. */
|
||||
private const val AUDIO_CODEC_PCM_WIRE = 2
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
private fun statLine(text: String, color: Color) {
|
||||
|
||||
@@ -58,7 +58,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -420,10 +419,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(true)
|
||||
}
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
|
||||
// handoff broke per-screen ownership, see the `immersive` effect there).
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
@@ -817,7 +814,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
@@ -916,6 +912,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
// The SurfaceView's on-screen pixel size — the coordinate space the
|
||||
// ASurfaceControl layer composites in (the aspect-fitted video rect,
|
||||
// not the window's rotated buffer geometry). 0 if not laid out yet;
|
||||
// native falls back to the window buffer size.
|
||||
this@apply.width,
|
||||
this@apply.height,
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
|
||||
// The MIC grant is read live (a surface recreate re-runs this, and
|
||||
@@ -938,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// The view's CURRENT pixel size, for the ASurfaceControl layer's
|
||||
// destination rect. It is reported here and not only at
|
||||
// surfaceCreated because the view grows a frame or two after the
|
||||
// stream screen appears — hiding the system bars and switching on
|
||||
// cutout drawing both resize it, and neither recreates the surface.
|
||||
// A layer left on the start-up rect paints the picture small, in the
|
||||
// top-left corner. The view's own size, not the buffer geometry in
|
||||
// `width`/`height`: the layer composites in the view's space.
|
||||
NativeBridge.nativeVideoSurfaceSize(
|
||||
handle, this@apply.width, this@apply.height,
|
||||
)
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package io.unom.punktfunk.console
|
||||
|
||||
import android.view.InputDevice
|
||||
import io.unom.punktfunk.MouseMode
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.StreamProfile
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.padInfoOf
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The JSON that crosses into the Skia console — written in the console's OWN model shapes
|
||||
* (`crates/pf-console-ui/src/model.rs` `HostRow`/`WakeStatus`/`PairPhase`, `library.rs`
|
||||
* `LibraryGame`/`LibraryPhase`, `pf-client-core/src/trust.rs` `Settings`/`KnownHosts`), so there
|
||||
* is no Android-side mirror type to drift; the Rust structs deserialize these directly.
|
||||
*/
|
||||
internal object ConsoleJson {
|
||||
// ---- host rows (`HostRow`) ------------------------------------------------------------
|
||||
|
||||
/** `HostRow.key` — the pinned fingerprint when there is one, else `addr:port` (Rust parity). */
|
||||
fun rowKey(fpHex: String, address: String, port: Int): String =
|
||||
if (fpHex.isEmpty()) "$address:$port" else fpHex
|
||||
|
||||
private fun profileChip(p: StreamProfile): JSONObject = JSONObject()
|
||||
.put("id", p.id)
|
||||
.put("name", p.name)
|
||||
.put("accent", p.accent ?: JSONObject.NULL)
|
||||
|
||||
/**
|
||||
* The home carousel: saved hosts (name order — Android records carry no last-used time),
|
||||
* each followed by its pinned profile cards, then discovered-but-unsaved hosts. Mirrors
|
||||
* `clients/session/src/console.rs::rows()` — the desktop service's ordering — so a
|
||||
* player who moves between a Deck and a phone finds the same carousel.
|
||||
*/
|
||||
fun hostRows(
|
||||
saved: List<KnownHost>,
|
||||
discovered: List<DiscoveredHost>,
|
||||
reachable: Set<String>,
|
||||
profiles: List<StreamProfile>,
|
||||
): String {
|
||||
val out = JSONArray()
|
||||
fun advertFor(h: KnownHost): DiscoveredHost? = discovered.firstOrNull { d ->
|
||||
(h.fpHex.isNotEmpty() && d.fingerprint.equals(h.fpHex, ignoreCase = true)) ||
|
||||
(d.host == h.address && d.port == h.port)
|
||||
}
|
||||
for (h in saved.sortedBy { it.name.lowercase() }) {
|
||||
val key = rowKey(h.fpHex, h.address, h.port)
|
||||
val advert = advertFor(h)
|
||||
val online = advert != null || "${h.address}:${h.port}" in reachable
|
||||
val base = JSONObject()
|
||||
.put("key", key)
|
||||
.put("name", h.name.ifBlank { h.address })
|
||||
.put("addr", h.address)
|
||||
.put("port", h.port)
|
||||
.put("fp_hex", h.fpHex)
|
||||
.put("paired", h.paired)
|
||||
.put("saved", true)
|
||||
.put("online", online)
|
||||
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", !online && h.mac.isNotEmpty())
|
||||
.put("clipboard_sync", h.clipboardSync)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
|
||||
.put("pin", JSONObject.NULL)
|
||||
.put(
|
||||
"bound_profile",
|
||||
h.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
?.let(::profileChip) ?: JSONObject.NULL,
|
||||
)
|
||||
out.put(base)
|
||||
// A pinned card shares the primary tile's live state; its key rides the profile id
|
||||
// behind a NUL (impossible in a fingerprint or `addr:port`) — Rust parity.
|
||||
for (pid in h.pinnedProfileIds) {
|
||||
val p = profiles.firstOrNull { it.id == pid } ?: continue
|
||||
out.put(
|
||||
JSONObject(base.toString())
|
||||
.put("key", "$key\u0000${p.id}")
|
||||
.put("pin", profileChip(p))
|
||||
.put("bound_profile", JSONObject.NULL),
|
||||
)
|
||||
}
|
||||
}
|
||||
val extra = discovered.filter { d ->
|
||||
saved.none { h ->
|
||||
(h.fpHex.isNotEmpty() && h.fpHex.equals(d.fingerprint, ignoreCase = true)) ||
|
||||
(h.address == d.host && h.port == d.port)
|
||||
}
|
||||
}.sortedBy { it.name.lowercase() }
|
||||
for (d in extra) {
|
||||
val fp = d.fingerprint.orEmpty()
|
||||
out.put(
|
||||
JSONObject()
|
||||
.put("key", rowKey(fp, d.host, d.port))
|
||||
.put("name", d.name.ifBlank { d.host })
|
||||
.put("addr", d.host)
|
||||
.put("port", d.port)
|
||||
.put("fp_hex", fp)
|
||||
.put("paired", false)
|
||||
.put("saved", false)
|
||||
.put("online", true)
|
||||
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", false)
|
||||
.put("clipboard_sync", false)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", d.os)
|
||||
.put("pin", JSONObject.NULL)
|
||||
.put("bound_profile", JSONObject.NULL),
|
||||
)
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** One `HostRow` for a console entry (`{"library": <HostRow>}`) — the shelf to open. */
|
||||
fun hostRow(h: KnownHost, pin: StreamProfile?, profiles: List<StreamProfile>): JSONObject {
|
||||
val key = rowKey(h.fpHex, h.address, h.port)
|
||||
return JSONObject()
|
||||
.put("key", if (pin == null) key else "$key\u0000${pin.id}")
|
||||
.put("name", h.name.ifBlank { h.address })
|
||||
.put("addr", h.address)
|
||||
.put("port", h.port)
|
||||
.put("fp_hex", h.fpHex)
|
||||
.put("paired", h.paired)
|
||||
.put("saved", true)
|
||||
.put("online", true)
|
||||
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
|
||||
.put("can_wake", false)
|
||||
.put("clipboard_sync", h.clipboardSync)
|
||||
.put("last_used", JSONObject.NULL)
|
||||
.put("os", h.os)
|
||||
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
|
||||
.put(
|
||||
"bound_profile",
|
||||
if (pin != null) JSONObject.NULL
|
||||
else h.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
?.let(::profileChip) ?: JSONObject.NULL,
|
||||
)
|
||||
}
|
||||
|
||||
/** `KnownHosts` (Rust) — only what the console needs to build a link: id, address, fp. */
|
||||
fun knownHosts(saved: List<KnownHost>): String {
|
||||
val hosts = JSONArray()
|
||||
for (h in saved) {
|
||||
hosts.put(
|
||||
JSONObject()
|
||||
.put("name", h.name)
|
||||
.put("addr", h.address)
|
||||
.put("port", h.port)
|
||||
.put("fp_hex", h.fpHex)
|
||||
.put("paired", h.paired)
|
||||
.put("id", h.id)
|
||||
.put("mac", JSONArray(h.mac))
|
||||
.put("os", h.os)
|
||||
.put("mgmt_port", h.mgmtPort ?: JSONObject.NULL)
|
||||
.put("profile_id", h.profileId ?: JSONObject.NULL)
|
||||
.put("pinned_profiles", JSONArray(h.pinnedProfileIds)),
|
||||
)
|
||||
}
|
||||
return JSONObject().put("hosts", hosts).toString()
|
||||
}
|
||||
|
||||
fun profiles(profiles: List<StreamProfile>): String {
|
||||
val out = JSONArray()
|
||||
for (p in profiles) out.put(JSONArray().put(p.id).put(p.name))
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
// ---- wake / pair ------------------------------------------------------------------------
|
||||
|
||||
fun wakeStatus(
|
||||
key: String,
|
||||
name: String,
|
||||
seconds: Int,
|
||||
timedOut: Boolean,
|
||||
online: Boolean,
|
||||
thenConnect: Boolean,
|
||||
): String = JSONObject()
|
||||
.put("key", key)
|
||||
.put("name", name)
|
||||
.put("seconds", seconds)
|
||||
.put("timed_out", timedOut)
|
||||
.put("online", online)
|
||||
.put("then_connect", thenConnect)
|
||||
.toString()
|
||||
|
||||
fun pairIdle(): String = "\"Idle\""
|
||||
fun pairBusy(): String = "\"Busy\""
|
||||
fun pairFailed(msg: String): String = JSONObject().put("Failed", msg).toString()
|
||||
fun pairPaired(key: String): String =
|
||||
JSONObject().put("Paired", JSONObject().put("key", key)).toString()
|
||||
|
||||
// ---- library ------------------------------------------------------------------------------
|
||||
|
||||
/** `[LibraryGame]` from the Kotlin catalog — the desktop service's `to_model` mapping. */
|
||||
fun libraryGames(games: List<GameEntry>): String {
|
||||
val out = JSONArray()
|
||||
for (g in games) {
|
||||
out.put(
|
||||
JSONObject()
|
||||
.put("id", g.id)
|
||||
.put("title", g.title)
|
||||
.put("store", g.store)
|
||||
.put("launcher", g.isLauncher)
|
||||
.put("icon", g.icon?.takeIf(::validIconToken) ?: "")
|
||||
.put("platform", g.platform ?: JSONObject.NULL)
|
||||
.put("running", false),
|
||||
)
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** `GameEntry::icon_token`'s re-validation: lowercase-first, ≤ 32 chars of [a-z0-9-]. */
|
||||
private fun validIconToken(t: String): Boolean =
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
|
||||
fun libraryError(title: String, body: String, canRetry: Boolean): String = JSONObject()
|
||||
.put(
|
||||
"Error",
|
||||
JSONObject().put("title", title).put("body", body).put("can_retry", canRetry),
|
||||
)
|
||||
.toString()
|
||||
|
||||
fun stringArray(items: Collection<String>): String = JSONArray(items).toString()
|
||||
|
||||
// ---- pads -------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `{"label", "pref", "pads": [...]}` — the controller chip's text (the driving pad's name),
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
|
||||
* console's Connected-controllers screen.
|
||||
*
|
||||
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
|
||||
* Controllers screen renders from: the support answer a user gets must not depend on which
|
||||
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
|
||||
*/
|
||||
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
|
||||
val arr = JSONArray()
|
||||
for (d in pads) {
|
||||
val info = padInfoOf(d)
|
||||
val entry = JSONObject()
|
||||
.put("name", d.name)
|
||||
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
|
||||
.put("pref", Gamepad.prefFor(d))
|
||||
.put("steam_virtual", false)
|
||||
.put("detail", info.detail)
|
||||
.put("forwarded", info.forwarded)
|
||||
.put("rumble", info.canRumble)
|
||||
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
|
||||
val b = d.batteryState
|
||||
if (b.isPresent && b.capacity >= 0f) {
|
||||
JSONObject()
|
||||
.put("percent", (b.capacity * 100f).toInt().coerceIn(0, 100))
|
||||
.put(
|
||||
"charging",
|
||||
b.status == android.os.BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
b.status == android.os.BatteryManager.BATTERY_STATUS_FULL,
|
||||
)
|
||||
} else null
|
||||
} else null
|
||||
entry.put("battery", battery ?: JSONObject.NULL)
|
||||
arr.put(entry)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("label", driving?.name ?: JSONObject.NULL)
|
||||
.put("pref", driving?.let { Gamepad.prefFor(it) } ?: JSONObject.NULL)
|
||||
.put("pads", arr)
|
||||
.toString()
|
||||
}
|
||||
|
||||
// ---- settings (`trust::Settings`) -------------------------------------------------------
|
||||
|
||||
private val GAMEPAD_NAMES = listOf(
|
||||
"auto", "xbox360", "dualsense", "xboxone", "dualshock4", "steamcontroller", "steamdeck",
|
||||
"dualsenseedge", "switchpro", "steamcontroller2", "steamcontroller2puck", "xboxelite",
|
||||
)
|
||||
private val COMPOSITOR_NAMES = listOf("auto", "kwin", "wlroots", "mutter", "gamescope")
|
||||
|
||||
/**
|
||||
* The console's settings document: [base] is the last snapshot the console saved (it owns
|
||||
* keys Android has no field for — `library_sort`, `library_view`, `reduce_motion`, …), and
|
||||
* every field Android DOES own is written over it from [s], so the touch UI's edits win.
|
||||
* `trust::Settings` is `#[serde(default)]`, so a partial document is fine.
|
||||
*/
|
||||
fun settings(s: Settings, base: JSONObject?): JSONObject {
|
||||
val j = base?.let { JSONObject(it.toString()) } ?: JSONObject()
|
||||
j.put("width", s.width)
|
||||
j.put("height", s.height)
|
||||
j.put("refresh_hz", s.hz)
|
||||
j.put("bitrate_kbps", s.bitrateKbps)
|
||||
j.put("render_scale", s.renderScale)
|
||||
j.put("gamepad", GAMEPAD_NAMES.getOrElse(s.gamepad) { "auto" })
|
||||
j.put("gamepad_forwarding", s.gamepadForwarding)
|
||||
j.put("system_buttons", s.systemButtons)
|
||||
j.put("guide_gesture", s.guideGesture)
|
||||
j.put("compositor", COMPOSITOR_NAMES.getOrElse(s.compositor) { "auto" })
|
||||
j.put("touch_mode", s.touchMode.name.lowercase())
|
||||
j.put("mouse_mode", s.mouseMode.storedName)
|
||||
j.put("mic_enabled", s.micEnabled)
|
||||
j.put("echo_cancel", s.echoCancel)
|
||||
j.put("audio_channels", s.audioChannels)
|
||||
j.put("audio_format", s.audioFormat)
|
||||
j.put("codec", s.codec)
|
||||
j.put("hdr_enabled", s.hdrEnabled)
|
||||
j.put("present_priority", s.presentPriority)
|
||||
j.put("smooth_buffer", s.smoothBuffer)
|
||||
j.put("show_stats", s.statsVerbosity != StatsVerbosity.OFF)
|
||||
j.put("stats_verbosity", s.statsVerbosity.name.lowercase())
|
||||
j.put("ui_palette", s.uiPalette)
|
||||
j.put("auto_wake", s.autoWakeEnabled)
|
||||
j.put("invert_scroll", s.invertScroll)
|
||||
j.put("pad_haptics", s.padHaptics)
|
||||
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
|
||||
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
|
||||
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
|
||||
// whole object into the map under the literal key "extra", where no console row could
|
||||
// read it and every value the console wrote came straight back as the one we had sent.
|
||||
j.put("android.low_latency", s.lowLatencyMode)
|
||||
j.put("android.rumble_on_phone", s.rumbleOnPhone)
|
||||
j.put("android.gyro_on_phone", s.gyroOnPhone)
|
||||
j.put("android.sc2_capture", s.sc2Capture)
|
||||
j.put("android.ds_capture", s.dsCapture)
|
||||
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
|
||||
// A store written by the nesting build carries the stale wrapper; drop it rather than
|
||||
// round-trip a copy of these keys that nothing reads for the life of the install.
|
||||
j.remove("extra")
|
||||
return j
|
||||
}
|
||||
|
||||
/**
|
||||
* The console saved [j]: fold every key Android owns back into [s]. Unknown values snap to
|
||||
* the field's current value — a newer console's spelling must never corrupt the store.
|
||||
*/
|
||||
fun applySettings(s: Settings, j: JSONObject): Settings {
|
||||
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
|
||||
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
|
||||
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
|
||||
return s.copy(
|
||||
width = j.optInt("width", s.width),
|
||||
height = j.optInt("height", s.height),
|
||||
hz = j.optInt("refresh_hz", s.hz),
|
||||
bitrateKbps = j.optInt("bitrate_kbps", s.bitrateKbps),
|
||||
renderScale = j.optDouble("render_scale", s.renderScale),
|
||||
gamepad = GAMEPAD_NAMES.indexOf(str("gamepad", "")).takeIf { it >= 0 } ?: s.gamepad,
|
||||
gamepadForwarding = j.optBoolean("gamepad_forwarding", s.gamepadForwarding),
|
||||
systemButtons = str("system_buttons", s.systemButtons),
|
||||
guideGesture = str("guide_gesture", s.guideGesture),
|
||||
compositor = COMPOSITOR_NAMES.indexOf(str("compositor", "")).takeIf { it >= 0 }
|
||||
?: s.compositor,
|
||||
touchMode = TouchMode.entries.firstOrNull { it.name.lowercase() == j.optString("touch_mode") }
|
||||
?: s.touchMode,
|
||||
mouseMode = MouseMode.entries.firstOrNull { it.storedName == j.optString("mouse_mode") }
|
||||
?: s.mouseMode,
|
||||
micEnabled = j.optBoolean("mic_enabled", s.micEnabled),
|
||||
echoCancel = j.optBoolean("echo_cancel", s.echoCancel),
|
||||
audioChannels = j.optInt("audio_channels", s.audioChannels),
|
||||
audioFormat = str("audio_format", s.audioFormat),
|
||||
codec = str("codec", s.codec),
|
||||
hdrEnabled = j.optBoolean("hdr_enabled", s.hdrEnabled),
|
||||
presentPriority = str("present_priority", s.presentPriority),
|
||||
smoothBuffer = j.optInt("smooth_buffer", s.smoothBuffer),
|
||||
statsVerbosity = StatsVerbosity.entries
|
||||
.firstOrNull { it.name.lowercase() == j.optString("stats_verbosity") }
|
||||
?: s.statsVerbosity,
|
||||
uiPalette = str("ui_palette", s.uiPalette),
|
||||
autoWakeEnabled = j.optBoolean("auto_wake", s.autoWakeEnabled),
|
||||
invertScroll = j.optBoolean("invert_scroll", s.invertScroll),
|
||||
padHaptics = j.optBoolean("pad_haptics", s.padHaptics),
|
||||
padSpeaker = when (j.optString("pad_speaker", "")) {
|
||||
"pad", "mix" -> true
|
||||
"off" -> false
|
||||
else -> s.padSpeaker
|
||||
},
|
||||
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
|
||||
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
|
||||
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
|
||||
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
|
||||
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
|
||||
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
|
||||
.ifEmpty { s.gamepadUiMode },
|
||||
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
package io.unom.punktfunk.console
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
|
||||
import io.unom.punktfunk.ConnectErrors
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.SettingsStore
|
||||
import io.unom.punktfunk.StreamProfile
|
||||
import io.unom.punktfunk.connectToHost
|
||||
import io.unom.punktfunk.deviceName
|
||||
import io.unom.punktfunk.effectiveFor
|
||||
import io.unom.punktfunk.matches
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
import io.unom.punktfunk.kit.library.LibraryCache
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The Skia console (`crates/pf-console-ui`, drawn by native over EGL — design
|
||||
* `android-skia-console-port.md`) as this app holds it: ONE instance for the process, created
|
||||
* lazily and never torn down while the app lives, so the console's screen stack survives a trip
|
||||
* through the stream exactly as the desktop's does (the shelf is where you left it when the game
|
||||
* exits). [SkiaConsoleShell] attaches a surface, the pad probes and the overlays to it while the
|
||||
* console is on screen; between, it idles parked.
|
||||
*
|
||||
* This object is the SERVICE side of the console's model (`ConsoleShared` / `LibraryShared` /
|
||||
* `ConsoleBus`): it feeds host rows from the trust store + discovery + the reachability probe,
|
||||
* runs the library fetch/cache/art pipeline, pairing, wake-and-wait, and the settings round-trip,
|
||||
* and turns the console's own asks (`OverlayAction`) into a connect, a clipboard write, or a
|
||||
* task-to-back. Everything blocking runs on its own executor; every native call is cheap.
|
||||
*/
|
||||
object SkiaConsole {
|
||||
private const val TAG = "pf.console"
|
||||
|
||||
/**
|
||||
* On-glass triage switch: `adb shell setprop debug.punktfunk.console_backend none` makes the
|
||||
* app behave as if the native console host were absent (the touch UI fronts everything, a
|
||||
* controller drives it through Compose focus). Anything else = the console.
|
||||
*/
|
||||
private const val BACKEND_PROP = "debug.punktfunk.console_backend"
|
||||
|
||||
/** Where the console-owned settings keys (`library_view`, `reduce_motion`, …) persist. */
|
||||
private const val PREFS = "punktfunk_console_settings"
|
||||
|
||||
private var handle = 0L
|
||||
|
||||
/**
|
||||
* False once the console has proven it cannot draw — the native create failed, or the render
|
||||
* thread died (a GL context that never came up, or one Android reclaimed and that would not
|
||||
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
|
||||
* dead console is the touch UI — not the gray, never-painted `SurfaceView` the shell would
|
||||
* otherwise sit on for the rest of the process.
|
||||
*/
|
||||
var healthy by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
|
||||
private val artPool = Executors.newFixedThreadPool(3) { r -> Thread(r, "pf-console-art").apply { isDaemon = true } }
|
||||
private val artHttp by lazy { OkHttpClient() }
|
||||
private var eventThread: Thread? = null
|
||||
private val running = AtomicBoolean(false)
|
||||
|
||||
// Services.
|
||||
private lateinit var knownHostStore: KnownHostStore
|
||||
private lateinit var profileStore: ProfileStore
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private var identity: ClientIdentity? = null
|
||||
private var discovery: HostDiscovery? = null
|
||||
private var discovered: List<DiscoveredHost> = emptyList()
|
||||
private var reachable: Set<String> = emptySet()
|
||||
private var settings: Settings = Settings()
|
||||
|
||||
// What the composable hands us while it is on screen.
|
||||
private var onConnected: ((ActiveSession) -> Unit)? = null
|
||||
private var onSettingsChange: ((Settings) -> Unit)? = null
|
||||
private var onQuit: (() -> Unit)? = null
|
||||
private var onPlatformScreen: ((String) -> Unit)? = null
|
||||
private var onPadAction: ((String, String) -> Unit)? = null
|
||||
private var onPulse: ((String) -> Unit)? = null
|
||||
|
||||
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
|
||||
private class Dial(val cancelled: AtomicBoolean = AtomicBoolean(false))
|
||||
private var dial: Dial? = null
|
||||
|
||||
/** The wake-and-wait loop in flight, if any. */
|
||||
private var wakeGen = AtomicLong(0)
|
||||
|
||||
/** The library fetch in flight (its generation; a newer one supersedes it). */
|
||||
private val fetchGen = AtomicLong(0)
|
||||
|
||||
// ---- availability -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether the console can front the gamepad UI on this device: the native host must be in
|
||||
* this build (every shipping ABI today — see `nativeConsoleAvailable`) and the triage sysprop
|
||||
* must not say `none`.
|
||||
*/
|
||||
fun wanted(): Boolean {
|
||||
val available = runCatching { NativeBridge.nativeConsoleAvailable() }.getOrDefault(false)
|
||||
if (!available) return false
|
||||
return backendProp() != "none"
|
||||
}
|
||||
|
||||
private fun backendProp(): String = runCatching {
|
||||
val cls = Class.forName("android.os.SystemProperties")
|
||||
cls.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, BACKEND_PROP, "") as String
|
||||
}.getOrDefault("").trim().lowercase()
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the console if it does not exist yet. Idempotent; call from the main thread. Returns
|
||||
* the native handle (`0` = the console could not be built; the caller keeps the Compose
|
||||
* console).
|
||||
*/
|
||||
fun ensure(context: Context, initial: Settings): Long {
|
||||
if (handle != 0L) return handle
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
knownHostStore = KnownHostStore(app)
|
||||
profileStore = ProfileStore(app)
|
||||
settingsStore = SettingsStore(app)
|
||||
settings = initial
|
||||
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val base = prefs.getString("json", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
val profiles = profileStore.all()
|
||||
val opts = JSONObject()
|
||||
.put("device_name", deviceName(app))
|
||||
.put("gpu_cache_bytes", gpuCacheBytes(app))
|
||||
// The touch shell exists as a fallback on phones/tablets but not on a TV —
|
||||
// gates the console's own "Controller-optimized UI" off switch.
|
||||
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
|
||||
.put("settings", ConsoleJson.settings(initial, base))
|
||||
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
|
||||
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
|
||||
.put("entry", JSONObject())
|
||||
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
|
||||
if (handle == 0L) {
|
||||
Log.e(TAG, "console: native create failed")
|
||||
healthy = false // see [healthy] — the touch UI fronts everything from here
|
||||
return 0L
|
||||
}
|
||||
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
|
||||
startEventThread()
|
||||
startServices(app)
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Skia's resource budget: a quarter of the desktop's 160 MB on a ≤ 2 GB box, the desktop
|
||||
* figure above (design D11 — a 160 MB texture cache is how a TV box gets its process killed).
|
||||
*/
|
||||
private fun gpuCacheBytes(context: Context): Int {
|
||||
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
val classMb = am?.memoryClass ?: 128
|
||||
return if (classMb >= 256) 160 shl 20 else 64 shl 20
|
||||
}
|
||||
|
||||
private fun startServices(app: Context) {
|
||||
ioPool.execute {
|
||||
identity = runCatching { obtainIdentity(IdentityStore(app)) }
|
||||
.onFailure { Log.w(TAG, "identity unavailable: ${it.message}") }
|
||||
.getOrNull()
|
||||
}
|
||||
val d = HostDiscovery(app)
|
||||
d.onChange = { list ->
|
||||
discovered = list
|
||||
// Learn wake MACs / mgmt ports from live adverts, as the desktop service does.
|
||||
ioPool.execute {
|
||||
var changed = false
|
||||
for (dh in list) {
|
||||
val kh = knownHostStore.all().firstOrNull { it.matches(dh) } ?: continue
|
||||
if (dh.mac.isNotEmpty() && dh.mac.toSet() != kh.mac.toSet()) {
|
||||
knownHostStore.learnMac(kh.address, kh.port, dh.mac); changed = true
|
||||
}
|
||||
dh.mgmtPort?.let { if (it != kh.mgmtPort) { knownHostStore.learnMgmtPort(kh.address, kh.port, it); changed = true } }
|
||||
if (dh.os.isNotEmpty() && dh.os != kh.os) { knownHostStore.learnOs(kh.address, kh.port, dh.os); changed = true }
|
||||
}
|
||||
main.post { pushHosts(); if (changed) pushKnownHosts() }
|
||||
}
|
||||
pushHosts()
|
||||
}
|
||||
discovery = d
|
||||
d.start()
|
||||
// The reachability sweep: saved hosts not on mDNS, every ~12 s (the desktop's cadence).
|
||||
main.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (handle == 0L) return
|
||||
// Only while the console is ON SCREEN (attached): parked behind the touch UI
|
||||
// or a stream there is nobody to show the presence pips to — and mid-stream
|
||||
// the radio belongs to the session, which is exactly why discovery stops for
|
||||
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
|
||||
if (onConnected == null) {
|
||||
main.postDelayed(this, 12_000)
|
||||
return
|
||||
}
|
||||
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
|
||||
ioPool.execute {
|
||||
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
|
||||
.map { "${it.address}:${it.port}" }.toSet()
|
||||
main.post { if (up != reachable) { reachable = up; pushHosts() } }
|
||||
}
|
||||
main.postDelayed(this, 12_000)
|
||||
}
|
||||
})
|
||||
// Commands from the console, drained on a short cadence.
|
||||
main.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (handle == 0L) return
|
||||
drainCommands()
|
||||
main.postDelayed(this, 100)
|
||||
}
|
||||
})
|
||||
pushHosts()
|
||||
}
|
||||
|
||||
private fun startEventThread() {
|
||||
running.set(true)
|
||||
eventThread = Thread({
|
||||
while (running.get() && handle != 0L) {
|
||||
val json = runCatching { NativeBridge.nativeConsoleNextEvent(handle) }.getOrDefault("")
|
||||
if (json.isEmpty()) continue
|
||||
val ev = runCatching { JSONObject(json) }.getOrNull() ?: continue
|
||||
main.post { onEvent(ev) }
|
||||
}
|
||||
}, "pf-console-events").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
// ---- what the composable attaches ------------------------------------------------------
|
||||
|
||||
fun attach(
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
onPlatformScreen: (String) -> Unit,
|
||||
onPadAction: (String, String) -> Unit,
|
||||
onPulse: (String) -> Unit,
|
||||
) {
|
||||
this.onConnected = onConnected
|
||||
this.onSettingsChange = onSettingsChange
|
||||
this.onQuit = onQuit
|
||||
this.onPlatformScreen = onPlatformScreen
|
||||
this.onPadAction = onPadAction
|
||||
this.onPulse = onPulse
|
||||
discovery?.restart()
|
||||
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
|
||||
pushHosts()
|
||||
pushKnownHosts()
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetProfiles(handle, ConsoleJson.profiles(profileStore.all()))
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
onConnected = null
|
||||
onSettingsChange = null
|
||||
onQuit = null
|
||||
onPlatformScreen = null
|
||||
onPadAction = null
|
||||
onPulse = null
|
||||
}
|
||||
|
||||
/** The touch UI (or a link) changed settings: the console reads the new snapshot next. */
|
||||
fun settingsChanged(s: Settings) {
|
||||
settings = s
|
||||
if (handle == 0L) return
|
||||
val prefs = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val base = prefs?.getString("json", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
NativeBridge.nativeConsoleSetSettings(handle, ConsoleJson.settings(s, base).toString())
|
||||
}
|
||||
|
||||
/** The profile catalog changed (the touch settings edited it). */
|
||||
fun profilesChanged() {
|
||||
if (handle == 0L) return
|
||||
NativeBridge.nativeConsoleSetProfiles(handle, ConsoleJson.profiles(profileStore.all()))
|
||||
pushHosts()
|
||||
}
|
||||
|
||||
/** The host store changed outside the console (touch UI pairing / forget). */
|
||||
fun hostsChanged() {
|
||||
if (handle == 0L) return
|
||||
pushHosts()
|
||||
pushKnownHosts()
|
||||
}
|
||||
|
||||
/** A session the console started (or any session) has ended; [reason] = the abnormal one. */
|
||||
fun sessionEnded(reason: String?) {
|
||||
if (handle == 0L) return
|
||||
NativeBridge.nativeConsoleSessionPhase(handle, 3, reason.orEmpty())
|
||||
discovery?.restart()
|
||||
}
|
||||
|
||||
/** Re-root the console on a host's shelf (a game launched from it just exited; a deep link). */
|
||||
fun openLibrary(hostId: String, pinId: String?) {
|
||||
if (handle == 0L) return
|
||||
val kh = knownHostStore.byId(hostId) ?: return
|
||||
val profiles = profileStore.all()
|
||||
val pin = pinId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
val entry = JSONObject().put("library", ConsoleJson.hostRow(kh, pin, profiles))
|
||||
NativeBridge.nativeConsoleNavigate(handle, entry.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
|
||||
* (the same dial the console's own Launch takes); anything that would need a trust decision
|
||||
* is a notice here — a link may never establish trust, and the console's Pair screen is
|
||||
* reached from the host's tile, not from a URL.
|
||||
*/
|
||||
fun handleDeepLink(url: String) {
|
||||
if (handle == 0L) return
|
||||
val parsed = io.unom.punktfunk.kit.link.DeepLinks.parse(url)
|
||||
if (parsed is io.unom.punktfunk.kit.link.DeepLinkResult.Refused) {
|
||||
if (parsed.error != io.unom.punktfunk.kit.link.LinkError.NOT_OUR_SCHEME) notice(parsed.message())
|
||||
return
|
||||
}
|
||||
val link = (parsed as io.unom.punktfunk.kit.link.DeepLinkResult.Parsed).link
|
||||
if (link.route != io.unom.punktfunk.kit.link.LinkRoute.CONNECT) {
|
||||
notice("Punktfunk on Android can't do “${link.route.word}” links yet.")
|
||||
return
|
||||
}
|
||||
val profileRef = link.profile
|
||||
if (profileRef != null) {
|
||||
val (_, resolution) = profileStore.resolve(profileRef)
|
||||
if (resolution != io.unom.punktfunk.ProfileResolution.FOUND) {
|
||||
notice("That link asks for a profile called “$profileRef”, which isn't on this device.")
|
||||
return
|
||||
}
|
||||
}
|
||||
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
|
||||
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
|
||||
val kh = resolved.host
|
||||
if (link.pinConflict(kh)) {
|
||||
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
|
||||
return
|
||||
}
|
||||
if (kh.fpHex.isEmpty() || !kh.paired) {
|
||||
notice("Pair with ${kh.name} first — a link can't establish trust.")
|
||||
return
|
||||
}
|
||||
launch(
|
||||
JSONObject()
|
||||
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
|
||||
.put("launch", link.launch ?: JSONObject.NULL)
|
||||
.put("profile", profileRef?.let { profileStore.resolve(it).first?.id } ?: JSONObject.NULL)
|
||||
.put("request_access", false),
|
||||
)
|
||||
}
|
||||
is io.unom.punktfunk.kit.link.HostResolution.Unknown ->
|
||||
notice("That link points at a host this device hasn't paired with.")
|
||||
io.unom.punktfunk.kit.link.HostResolution.Ambiguous ->
|
||||
notice("More than one saved host is called “${link.hostRef}”.")
|
||||
io.unom.punktfunk.kit.link.HostResolution.Unresolvable ->
|
||||
notice("That link points at a host this device doesn't know.")
|
||||
}
|
||||
}
|
||||
|
||||
/** The connected controllers, for the chip + settings rows. */
|
||||
fun padsChanged(driving: InputDevice?) {
|
||||
if (handle == 0L) return
|
||||
NativeBridge.nativeConsoleSetPads(handle, ConsoleJson.pads(Gamepad.pads(), driving ?: Gamepad.firstPad()))
|
||||
}
|
||||
|
||||
// ---- model pushers -----------------------------------------------------------------------
|
||||
|
||||
private fun pushHosts() {
|
||||
if (handle == 0L) return
|
||||
NativeBridge.nativeConsoleSetHosts(
|
||||
handle,
|
||||
ConsoleJson.hostRows(knownHostStore.all(), discovered, reachable, profileStore.all()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun pushKnownHosts() {
|
||||
if (handle == 0L) return
|
||||
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
|
||||
}
|
||||
|
||||
internal fun notice(text: String) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
|
||||
}
|
||||
|
||||
// ---- events from the console ---------------------------------------------------------
|
||||
|
||||
private fun onEvent(ev: JSONObject) {
|
||||
when {
|
||||
ev.has("action") -> onAction(ev.get("action"))
|
||||
ev.has("pulse") -> onPulse?.invoke(ev.optString("pulse"))
|
||||
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
|
||||
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
|
||||
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
|
||||
ev.has("dead") -> {
|
||||
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
|
||||
healthy = false // the touch UI takes over; only a process restart tries again
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSettingsSaved(j: JSONObject) {
|
||||
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
|
||||
?.putString("json", j.toString())?.apply()
|
||||
val next = ConsoleJson.applySettings(settings, j)
|
||||
if (next != settings) {
|
||||
settings = next
|
||||
settingsStore.save(next)
|
||||
onSettingsChange?.invoke(next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAction(action: Any) {
|
||||
when (action) {
|
||||
is String -> when (action) {
|
||||
"Quit" -> onQuit?.invoke()
|
||||
"CancelConnect" -> {
|
||||
dial?.cancelled?.set(true)
|
||||
dial = null
|
||||
discovery?.restart()
|
||||
}
|
||||
}
|
||||
is JSONObject -> {
|
||||
action.optJSONObject("Launch")?.let(::launch)
|
||||
action.optString("CopyText").takeIf { action.has("CopyText") }?.let { text ->
|
||||
val cm = appContext?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
cm?.setPrimaryClip(ClipData.newPlainText("punktfunk", text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `OverlayAction::Launch` — the console asked for a session. The trust decision was the
|
||||
* console's (an unpaired host went to its Pair screen first), so this is the dial itself:
|
||||
* pinned by the row's fingerprint, with the host's bound profile or the pinned card's
|
||||
* one-off, and — for the pair screen's "Request access" — the long approval budget.
|
||||
*/
|
||||
private fun launch(a: JSONObject) {
|
||||
val app = appContext ?: return
|
||||
val addr = a.optString("addr")
|
||||
val port = a.optInt("port")
|
||||
val fp = a.optString("fp_hex")
|
||||
val launchId = a.optString("launch").takeIf { a.has("launch") && !a.isNull("launch") && it.isNotEmpty() }
|
||||
val profileId = a.optString("profile").takeIf { a.has("profile") && !a.isNull("profile") && it.isNotEmpty() }
|
||||
val requestAccess = a.optBoolean("request_access", false)
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
NativeBridge.nativeConsoleSessionPhase(handle, 2, "Identity not ready yet — try again in a moment")
|
||||
return
|
||||
}
|
||||
val kh = knownHostStore.get(addr, port)
|
||||
val profile: StreamProfile? = profileStore.resolveFor(kh, profileId)
|
||||
val effective = settings.effectiveFor(profile)
|
||||
val d = Dial()
|
||||
dial = d
|
||||
NativeBridge.nativeConsoleSessionPhase(handle, 0, "")
|
||||
discovery?.stop() // free the Wi-Fi radio before the stream session
|
||||
ioPool.execute {
|
||||
val timeout = if (requestAccess) REQUEST_ACCESS_TIMEOUT_MS else CONNECT_TIMEOUT_MS
|
||||
val h = kotlinx.coroutines.runBlocking {
|
||||
connectToHost(app, effective, id, addr, port, fp, launchId, timeout)
|
||||
}
|
||||
main.post {
|
||||
if (d.cancelled.get()) {
|
||||
if (h != 0L) ioPool.execute { NativeBridge.nativeClose(h) }
|
||||
return@post
|
||||
}
|
||||
dial = null
|
||||
if (h != 0L) {
|
||||
var record = kh
|
||||
// A request-access approval, or a first TOFU-less connect: save the host as
|
||||
// PAIRED, pinning what it presented, so the next connect is silent.
|
||||
if (record == null || (requestAccess && !record.paired)) {
|
||||
val seen = NativeBridge.nativeHostFingerprint(h)
|
||||
if (seen.isNotEmpty()) {
|
||||
val name = record?.name
|
||||
?: discovered.firstOrNull { it.host == addr && it.port == port }?.name
|
||||
?: addr
|
||||
record = knownHostStore.trust(addr, port, name, seen, paired = requestAccess || record?.paired == true)
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
}
|
||||
if (record != null) {
|
||||
NativeBridge.nativeHostMgmtPort(h).takeIf { it > 0 }?.let {
|
||||
knownHostStore.learnMgmtPort(record.address, record.port, it)
|
||||
}
|
||||
}
|
||||
NativeBridge.nativeConsoleSessionPhase(handle, 1, "")
|
||||
onConnected?.invoke(
|
||||
ActiveSession(
|
||||
h,
|
||||
effective,
|
||||
clipboardSync = record?.clipboardSync ?: true,
|
||||
profileName = profile?.name,
|
||||
hostId = record?.id,
|
||||
launchedFromLibrary = launchId != null,
|
||||
libraryProfileId = profileId,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
NativeBridge.nativeConsoleSessionPhase(
|
||||
handle, 2, ConnectErrors.connectMessage(token, requestAccess),
|
||||
)
|
||||
discovery?.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- commands from the console -----------------------------------------------------
|
||||
|
||||
private fun drainCommands() {
|
||||
val arr = runCatching { JSONArray(NativeBridge.nativeConsoleDrainCmds(handle)) }.getOrNull() ?: return
|
||||
for (i in 0 until arr.length()) {
|
||||
when (val c = arr.opt(i)) {
|
||||
is String -> when (c) {
|
||||
"CancelWake" -> { wakeGen.incrementAndGet(); NativeBridge.nativeConsoleSetWake(handle, "null") }
|
||||
"Probe" -> { discovery?.restart(); pushHosts() }
|
||||
}
|
||||
is JSONObject -> {
|
||||
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
|
||||
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
|
||||
c.optJSONObject("Pair")?.let(::pair)
|
||||
c.optJSONObject("SendLogs")?.let(::sendLogs)
|
||||
c.optJSONObject("SaveHost")?.let(::saveHost)
|
||||
c.optJSONObject("UpdateHost")?.let(::updateHost)
|
||||
c.optJSONObject("ForgetHost")?.let(::forgetHost)
|
||||
c.optJSONObject("Wake")?.let(::wake)
|
||||
c.optJSONObject("SetPin")?.let(::setPin)
|
||||
c.optJSONObject("BindProfile")?.let(::bindProfile)
|
||||
c.optJSONObject("SetClipboard")?.let(::setClipboard)
|
||||
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
|
||||
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
|
||||
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
|
||||
?.let { onPlatformScreen?.invoke(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hostForKey(key: String): KnownHost? {
|
||||
val primary = key.substringBefore('\u0000')
|
||||
return knownHostStore.all().firstOrNull { ConsoleJson.rowKey(it.fpHex, it.address, it.port) == primary }
|
||||
}
|
||||
|
||||
private fun saveHost(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val port = c.optInt("port"); val name = c.optString("name")
|
||||
val existing = knownHostStore.get(addr, port)
|
||||
if (existing != null) {
|
||||
if (name.isNotEmpty()) knownHostStore.save(existing.copy(name = name))
|
||||
} else {
|
||||
knownHostStore.save(KnownHost(address = addr, port = port, name = name.ifEmpty { addr }, fpHex = "", paired = false))
|
||||
}
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
private fun updateHost(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val name = c.optString("name").trim(); val addr = c.optString("addr"); val port = c.optInt("port")
|
||||
if (addr != kh.address || port != kh.port) knownHostStore.remove(kh)
|
||||
knownHostStore.save(kh.copy(name = name.ifEmpty { addr }, address = addr, port = port))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
private fun forgetHost(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
knownHostStore.remove(kh)
|
||||
appContext?.let { LibraryCache.standard(it.cacheDir).forget(kh.id) }
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
|
||||
private fun bindProfile(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val pid = c.optString("profile_id")
|
||||
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
|
||||
knownHostStore.save(kh.copy(profileId = pid))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
|
||||
private fun setClipboard(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
private fun setPin(c: JSONObject) {
|
||||
val kh = hostForKey(c.optString("key")) ?: return
|
||||
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
|
||||
val pins = kh.pinnedProfileIds.toMutableList()
|
||||
if (pin && pid !in pins) pins.add(pid) else if (!pin) pins.remove(pid)
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
pushHosts(); pushKnownHosts()
|
||||
}
|
||||
|
||||
/**
|
||||
* `ConsoleCmd::SendLogs` — the native log ring (`nativeRenderLogs`) posted to this
|
||||
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
|
||||
* uses; the result comes back as a notice, in the desktop console's wording. The header
|
||||
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) — client
|
||||
* log bundle`).
|
||||
*/
|
||||
private fun sendLogs(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
|
||||
val hostName = c.optString("host_name").ifEmpty { addr }
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
notice("Identity not ready yet — try again in a moment")
|
||||
return
|
||||
}
|
||||
val version = appContext?.let { app ->
|
||||
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
|
||||
} ?: "?"
|
||||
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
|
||||
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
|
||||
ioPool.execute {
|
||||
val err = runCatching {
|
||||
val body = NativeBridge.nativeRenderLogs(header)
|
||||
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
|
||||
id.certPem, id.privateKeyPem, addr, fp,
|
||||
)
|
||||
val req = Request.Builder()
|
||||
.url("https://$addr:$mgmt/api/v1/client-logs")
|
||||
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
|
||||
}
|
||||
}.getOrElse { it.message ?: "upload failed" }
|
||||
main.post {
|
||||
notice(
|
||||
if (err.isEmpty()) {
|
||||
"Logs sent to $hostName — download them from its web console's Logs page"
|
||||
} else {
|
||||
"Couldn't send logs — $err"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pair(c: JSONObject) {
|
||||
val addr = c.optString("addr"); val port = c.optInt("port")
|
||||
val pin = c.optString("pin"); val name = c.optString("device_name")
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairFailed("Identity not ready yet — try again in a moment"))
|
||||
return
|
||||
}
|
||||
val hostName = knownHostStore.get(addr, port)?.name
|
||||
?: discovered.firstOrNull { it.host == addr && it.port == port }?.name ?: addr
|
||||
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairBusy())
|
||||
ioPool.execute {
|
||||
val fp = runCatching { NativeBridge.nativePair(addr, port, id.certPem, id.privateKeyPem, pin, name) }.getOrDefault("")
|
||||
main.post {
|
||||
if (fp.isNotEmpty()) {
|
||||
knownHostStore.trust(addr, port, hostName, fp, paired = true)
|
||||
pushHosts(); pushKnownHosts()
|
||||
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairPaired(fp))
|
||||
} else {
|
||||
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairFailed(ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The wake-and-wait loop (the desktop's `spawn_wake`): resend the magic packet every 6 s,
|
||||
* probe once a second, 90 s timeout; the console reads `online`/`timed_out` off the status
|
||||
* and acts (a `then_connect` wake dials from the shell's side once online).
|
||||
*/
|
||||
private fun wake(c: JSONObject) {
|
||||
val key = c.optString("key"); val thenConnect = c.optBoolean("then_connect")
|
||||
val kh = hostForKey(key) ?: return
|
||||
if (kh.mac.isEmpty()) return
|
||||
val gen = wakeGen.incrementAndGet()
|
||||
val name = kh.name.ifBlank { kh.address }
|
||||
ioPool.execute {
|
||||
val started = System.currentTimeMillis()
|
||||
var lastPacket = 0L
|
||||
while (wakeGen.get() == gen && handle != 0L) {
|
||||
val elapsed = ((System.currentTimeMillis() - started) / 1000).toInt()
|
||||
val timedOut = elapsed >= 90
|
||||
if (!timedOut && System.currentTimeMillis() - lastPacket >= 6_000) {
|
||||
NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address)
|
||||
lastPacket = System.currentTimeMillis()
|
||||
}
|
||||
val online = NativeBridge.nativeProbe(kh.address, kh.port, 900) ||
|
||||
discovered.any { kh.matches(it) }
|
||||
if (wakeGen.get() != gen) return@execute
|
||||
NativeBridge.nativeConsoleSetWake(
|
||||
handle,
|
||||
ConsoleJson.wakeStatus(key, name, elapsed, timedOut, online, thenConnect),
|
||||
)
|
||||
if (online || timedOut) return@execute
|
||||
Thread.sleep(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The library pipeline (the desktop's `spawn_fetch`): cached shelf first, wake + retry
|
||||
* across the boot window when the host has a MAC, then the catalog, the running set and
|
||||
* the posters — each poster fetched over the same mTLS client and pushed as bytes.
|
||||
*/
|
||||
private fun fetchLibrary(c: JSONObject, refreshOnly: Boolean) {
|
||||
val app = appContext ?: return
|
||||
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
|
||||
val id = identity
|
||||
val kh = knownHostStore.all().firstOrNull { it.fpHex.equals(fp, true) && fp.isNotEmpty() }
|
||||
?: knownHostStore.get(addr, mgmt)
|
||||
if (refreshOnly) {
|
||||
if (id == null) return
|
||||
ioPool.execute {
|
||||
val up = LibraryClient.fetchRunning(addr, mgmt, id.certPem, id.privateKeyPem, fp)
|
||||
.filter { it.isUp }.mapNotNull { it.appId }
|
||||
main.post { if (handle != 0L) NativeBridge.nativeConsoleLibraryRunning(handle, ConsoleJson.stringArray(up)) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val gen = fetchGen.incrementAndGet()
|
||||
NativeBridge.nativeConsoleLibraryBegin(handle)
|
||||
if (id == null) {
|
||||
NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Couldn't load the library", "Identity not ready yet — try again in a moment", true))
|
||||
return
|
||||
}
|
||||
val cache = LibraryCache.standard(app.cacheDir)
|
||||
val cacheKey = kh?.id ?: fp.ifEmpty { "$addr:$mgmt" }
|
||||
ioPool.execute {
|
||||
val cached = cache.load(cacheKey)?.games?.takeIf { it.isNotEmpty() }
|
||||
if (cached != null) main.post { if (gen == fetchGen.get()) NativeBridge.nativeConsoleLibraryGames(handle, ConsoleJson.libraryGames(cached), true) }
|
||||
val macs = kh?.mac.orEmpty()
|
||||
val waking = macs.isNotEmpty() && settings.autoWakeEnabled
|
||||
if (waking) NativeBridge.nativeWakeOnLan(macs.joinToString(","), addr)
|
||||
val attempts = if (waking) 12 else 1
|
||||
var result: LibraryResult? = null
|
||||
for (attempt in 0 until attempts) {
|
||||
if (gen != fetchGen.get()) return@execute
|
||||
val r = LibraryClient.fetch(addr, mgmt, id.certPem, id.privateKeyPem, fp)
|
||||
result = r
|
||||
if (r is LibraryResult.Ok || r is LibraryResult.Unauthorized) break
|
||||
if (attempt + 1 >= attempts) break
|
||||
if (attempt % 2 == 1) NativeBridge.nativeWakeOnLan(macs.joinToString(","), addr)
|
||||
main.post { if (gen == fetchGen.get()) NativeBridge.nativeConsoleLibraryStale(handle, 1) }
|
||||
Thread.sleep(5_000)
|
||||
}
|
||||
if (gen != fetchGen.get()) return@execute
|
||||
when (val r = result) {
|
||||
is LibraryResult.Ok -> {
|
||||
val games = r.games
|
||||
cache.store(cacheKey, games)
|
||||
val up = LibraryClient.fetchRunning(addr, mgmt, id.certPem, id.privateKeyPem, fp)
|
||||
.filter { it.isUp }.mapNotNull { it.appId }
|
||||
main.post {
|
||||
if (gen != fetchGen.get()) return@post
|
||||
NativeBridge.nativeConsoleLibraryGames(handle, ConsoleJson.libraryGames(games), false)
|
||||
NativeBridge.nativeConsoleLibraryStale(handle, 0)
|
||||
NativeBridge.nativeConsoleLibraryRunning(handle, ConsoleJson.stringArray(up))
|
||||
}
|
||||
for (g in games) {
|
||||
val candidates = g.art.posterCandidates
|
||||
if (candidates.isEmpty()) continue
|
||||
artPool.execute {
|
||||
if (gen != fetchGen.get()) return@execute
|
||||
val bytes = fetchArt(candidates, id, addr, fp) ?: return@execute
|
||||
main.post { if (gen == fetchGen.get() && handle != 0L) NativeBridge.nativeConsoleLibraryArt(handle, g.id, bytes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
is LibraryResult.Unauthorized -> main.post {
|
||||
if (gen != fetchGen.get()) return@post
|
||||
if (cached != null) NativeBridge.nativeConsoleLibraryStale(handle, 2)
|
||||
else NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Not paired", r.message, false))
|
||||
}
|
||||
is LibraryResult.Error -> main.post {
|
||||
if (gen != fetchGen.get()) return@post
|
||||
if (cached != null) NativeBridge.nativeConsoleLibraryStale(handle, 2)
|
||||
else NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Couldn't load the library", r.message, true))
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One poster: the candidates in order, first success wins; the host's art proxy over mTLS. */
|
||||
private fun fetchArt(candidates: List<String>, id: ClientIdentity, addr: String, fp: String): ByteArray? {
|
||||
for (url in candidates) {
|
||||
val client = if (url.contains(addr)) {
|
||||
runCatching { io.unom.punktfunk.kit.library.mtlsHttpClient(id.certPem, id.privateKeyPem, addr, fp) }.getOrNull() ?: continue
|
||||
} else artHttp
|
||||
val bytes = runCatching {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
if (resp.code == 200) resp.body?.bytes()?.takeIf { it.isNotEmpty() && it.size <= 16 shl 20 } else null
|
||||
}
|
||||
}.getOrNull()
|
||||
if (bytes != null) return bytes
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The no-PIN request-access park (≥ the host's approval window) — ConnectScreen's figure. */
|
||||
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package io.unom.punktfunk.console
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
|
||||
import io.unom.punktfunk.MainActivity
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.SettingsStore
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
import io.unom.punktfunk.rememberConsoleHaptics
|
||||
import io.unom.punktfunk.testRumble
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The gamepad/console UI drawn by the Skia shell (`crates/pf-console-ui`), hosted on a
|
||||
* `SurfaceView` this composable owns and driven through [SkiaConsole]. Same call shape as the
|
||||
* Compose `GamepadShell` it replaces (`App.kt` picks one by [SkiaConsole.wanted]).
|
||||
*
|
||||
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
|
||||
* the pad probes (raw pad → the shared menu synthesizer, over JNI), the system Back, the
|
||||
* platform-native sub-screen the console can open (Licences — Compose, drawn over the surface;
|
||||
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
|
||||
* over on the way in (a deep link, "come back to this shelf").
|
||||
*/
|
||||
@Composable
|
||||
fun SkiaConsoleShell(
|
||||
settings: Settings,
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
reopenLibrary: LibraryReturn? = null,
|
||||
onReopenLibraryHandled: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
val handle = remember { SkiaConsole.ensure(context, settings) }
|
||||
val haptics = rememberConsoleHaptics()
|
||||
// A platform-native screen the console opened over itself (design D7): the console's own
|
||||
// input is held while it is up, and Back closes it.
|
||||
var platformScreen by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val currentOnConnected by rememberUpdatedState(onConnected)
|
||||
val currentOnSettingsChange by rememberUpdatedState(onSettingsChange)
|
||||
DisposableEffect(handle) {
|
||||
SkiaConsole.attach(
|
||||
onConnected = { currentOnConnected(it) },
|
||||
onSettingsChange = { currentOnSettingsChange(it) },
|
||||
onQuit = { activity?.moveTaskToBack(true) },
|
||||
onPlatformScreen = { platformScreen = it },
|
||||
onPadAction = { action, key -> padAction(activity, action, key) },
|
||||
onPulse = { pulse ->
|
||||
when (pulse) {
|
||||
"move" -> haptics.tick()
|
||||
"confirm" -> haptics.confirm()
|
||||
"boundary" -> haptics.boundary()
|
||||
}
|
||||
},
|
||||
)
|
||||
onDispose { SkiaConsole.detach() }
|
||||
}
|
||||
|
||||
// Settings edited elsewhere (the touch UI shares the store) reach the shell on its next read.
|
||||
LaunchedEffect(settings) { SkiaConsole.settingsChanged(settings) }
|
||||
|
||||
// "Come back to the shelf this game was launched from" — consumed once, on entry.
|
||||
LaunchedEffect(reopenLibrary) {
|
||||
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
|
||||
SkiaConsole.openLibrary(id, pinId)
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
// A `punktfunk://` link on the way in: consumed once; the bridge dials a known-and-pinned
|
||||
// host and refuses (with a notice) anything that would need a trust decision.
|
||||
LaunchedEffect(deepLink) {
|
||||
val url = deepLink ?: return@LaunchedEffect
|
||||
onDeepLinkHandled()
|
||||
SkiaConsole.handleDeepLink(url)
|
||||
}
|
||||
|
||||
// The console owns the whole panel while it fronts the app, exactly like the stream: the
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
|
||||
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
|
||||
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
|
||||
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
|
||||
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
|
||||
|
||||
// The safe area, in surface pixels: system bars ∪ display cutout — the NP3's landscape punch
|
||||
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
|
||||
// With the bars hidden above, this is normally just the cutout.
|
||||
val density = LocalDensity.current
|
||||
val ld = LocalLayoutDirection.current
|
||||
val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
val left = insets.getLeft(density, ld).toFloat()
|
||||
val top = insets.getTop(density).toFloat()
|
||||
val right = insets.getRight(density, ld).toFloat()
|
||||
val bottom = insets.getBottom(density).toFloat()
|
||||
// Design-unit scale: TVs take the couch formula (0 = the shell decides: a 4K panel is 2.7×,
|
||||
// the same 800-unit field as a Deck); a phone or tablet in the hand gets a density FLOOR
|
||||
// under that formula, so type never shrinks below what the touch UI draws at the same
|
||||
// density (design D5 — a bare height/800 on a 460 dpi phone lands ~26 % smaller than a Deck).
|
||||
// The 0.75 is the on-glass tuning knob — raised from 0.6 after a 460 dpi phone (Nothing
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
}
|
||||
|
||||
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
|
||||
// stick/HAT become one MenuSample the shared synthesizer turns into menu events; a TV remote's
|
||||
// D-pad keys (not SOURCE_GAMEPAD) go in as discrete events; hardware keys as `Key`s.
|
||||
val padState = remember { PadState() }
|
||||
val platformUp by rememberUpdatedState(platformScreen != null)
|
||||
DisposableEffect(handle, activity) {
|
||||
if (activity == null || handle == 0L) return@DisposableEffect onDispose {}
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
if (platformUp) return@probe false
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
|
||||
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (fromPad) {
|
||||
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
|
||||
// under other buttons' names, so read raw this console answered ✕ with whatever
|
||||
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
|
||||
// and the game must not disagree about which button a user pressed.
|
||||
val code = Gamepad.padKeyCode(ev)
|
||||
val bit = when (code) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> 0
|
||||
KeyEvent.KEYCODE_BUTTON_B -> 1
|
||||
KeyEvent.KEYCODE_BUTTON_X -> 2
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> 3
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> 4
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> 5
|
||||
else -> -1
|
||||
}
|
||||
if (bit >= 0) {
|
||||
padState.button(bit, down)
|
||||
padState.push(handle)
|
||||
// MainActivity already noted the driving pad (lastPadDeviceId) before this
|
||||
// probe ran; refresh the chip when the pad behind the buttons changes.
|
||||
if (padState.deviceId != ev.deviceId) {
|
||||
padState.deviceId = ev.deviceId
|
||||
SkiaConsole.padsChanged(ev.device)
|
||||
}
|
||||
return@probe true
|
||||
}
|
||||
val dbit = when (code) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 1
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> 2
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> 3
|
||||
else -> -1
|
||||
}
|
||||
if (dbit >= 0) {
|
||||
padState.dpad(dbit, down)
|
||||
padState.push(handle)
|
||||
return@probe true
|
||||
}
|
||||
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
|
||||
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
|
||||
return@probe true
|
||||
}
|
||||
return@probe false
|
||||
}
|
||||
// A remote / keyboard. D-pad keys and DPAD_CENTER as discrete events with the
|
||||
// framework's own repeat; the rest as console keys; printable text while editing.
|
||||
if (!down) {
|
||||
return@probe when (ev.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_SPACE,
|
||||
KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_DOWN -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val repeat = ev.repeatCount > 0
|
||||
when (ev.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> NativeBridge.nativeConsoleMenu(handle, 0)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> NativeBridge.nativeConsoleMenu(handle, 1)
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> NativeBridge.nativeConsoleMenu(handle, 2)
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> NativeBridge.nativeConsoleMenu(handle, 3)
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> if (!repeat) NativeBridge.nativeConsoleMenu(handle, 4)
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> NativeBridge.nativeConsoleKey(handle, 4, ev.isShiftPressed, repeat)
|
||||
KeyEvent.KEYCODE_SPACE -> NativeBridge.nativeConsoleKey(handle, 5, ev.isShiftPressed, repeat)
|
||||
KeyEvent.KEYCODE_ESCAPE -> NativeBridge.nativeConsoleKey(handle, 6, ev.isShiftPressed, repeat)
|
||||
KeyEvent.KEYCODE_BACK -> if (!repeat) NativeBridge.nativeConsoleMenu(handle, 5)
|
||||
KeyEvent.KEYCODE_DEL -> NativeBridge.nativeConsoleKey(handle, 7, ev.isShiftPressed, repeat)
|
||||
KeyEvent.KEYCODE_PAGE_UP -> NativeBridge.nativeConsoleKey(handle, 8, ev.isShiftPressed, repeat)
|
||||
KeyEvent.KEYCODE_PAGE_DOWN -> NativeBridge.nativeConsoleKey(handle, 9, ev.isShiftPressed, repeat)
|
||||
KeyEvent.KEYCODE_TAB -> NativeBridge.nativeConsoleKey(handle, 10, ev.isShiftPressed, repeat)
|
||||
else -> {
|
||||
val ch = ev.unicodeChar
|
||||
if (ch != 0 && !ev.isCtrlPressed && !ev.isAltPressed && ch >= 0x20) {
|
||||
NativeBridge.nativeConsoleText(handle, String(Character.toChars(ch)))
|
||||
} else {
|
||||
return@probe false
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (platformUp) return@probe false
|
||||
if (!ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && !ev.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
return@probe false
|
||||
}
|
||||
val lx = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
val ly = ev.getAxisValue(MotionEvent.AXIS_Y)
|
||||
val hx = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
val hy = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
padState.stick(lx, ly)
|
||||
padState.hat(hx, hy)
|
||||
padState.push(handle)
|
||||
true
|
||||
}
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity.pushPadProbes(probes)
|
||||
SkiaConsole.padsChanged(Gamepad.firstPad())
|
||||
onDispose {
|
||||
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
|
||||
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
|
||||
activity.removePadProbes(probes)
|
||||
padState.reset()
|
||||
if (handle != 0L) padState.push(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// The system Back (gesture or key) is the console's B; at its root the shell raises Quit.
|
||||
BackHandler(enabled = platformScreen == null) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleMenu(handle, 5)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
// The console draws opaque, edge to edge; Compose overlays sit above it.
|
||||
setZOrderMediaOverlay(false)
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(h: SurfaceHolder) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSurfaceCreated(handle, h.surface)
|
||||
}
|
||||
override fun surfaceChanged(h: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSurfaceChanged(handle)
|
||||
}
|
||||
override fun surfaceDestroyed(h: SurfaceHolder) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSurfaceDestroyed(handle)
|
||||
}
|
||||
})
|
||||
// Touch → the console's pointer (surface pixels): the escape hatch when no
|
||||
// pad is attached, and the natural way to press a legend hint on a phone.
|
||||
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
|
||||
// mouse — which Android delivers through this same listener — keeps kind 1
|
||||
// and acts on the press, as a mouse should.
|
||||
setOnTouchListener { v, ev ->
|
||||
if (handle == 0L) return@setOnTouchListener false
|
||||
val kind = when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN ->
|
||||
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
|
||||
MotionEvent.ACTION_MOVE -> 0
|
||||
MotionEvent.ACTION_UP -> 2
|
||||
MotionEvent.ACTION_CANCEL -> 5
|
||||
else -> return@setOnTouchListener false
|
||||
}
|
||||
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
|
||||
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
|
||||
true
|
||||
}
|
||||
setOnGenericMotionListener { _, ev ->
|
||||
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
|
||||
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
|
||||
) {
|
||||
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
true
|
||||
} else false
|
||||
}
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen — the handful of
|
||||
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
|
||||
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
|
||||
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
|
||||
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
|
||||
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
|
||||
*/
|
||||
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
|
||||
if (activity == null) return
|
||||
val settings = SettingsStore(activity).load()
|
||||
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
when (action) {
|
||||
"rumble" ->
|
||||
Gamepad.pads()
|
||||
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
|
||||
?.let(::testRumble)
|
||||
"sc2_bluetooth" -> when {
|
||||
!settings.sc2Capture ->
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
Sc2BleLink.permissionGranted(activity) ->
|
||||
SkiaConsole.notice("Bluetooth access is already granted.")
|
||||
// The system dialog pauses the activity; onResume re-probes and engages the capture,
|
||||
// the same way the menu-time auto-ask completes.
|
||||
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
|
||||
}
|
||||
}
|
||||
"sc2_usb" ->
|
||||
if (!settings.sc2Capture) {
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
} else {
|
||||
// Asks for the USB grant when one is missing and engages the capture on it.
|
||||
activity.startSc2MenuNav(forceAsk = true)
|
||||
}
|
||||
"ds_usb" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
!settings.dsCapture ->
|
||||
SkiaConsole.notice(
|
||||
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
|
||||
)
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
|
||||
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
|
||||
else -> usb.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
activity, 3, // requestCode 3 — shared with the touch card's button
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
"ds_haptics" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
|
||||
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
|
||||
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
|
||||
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
|
||||
else -> Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be shared with
|
||||
// another transfer engine, and that applies to this test as much as to the
|
||||
// real path (same rule as the touch card's test).
|
||||
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
|
||||
conn?.close()
|
||||
SkiaConsole.notice(
|
||||
when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 ->
|
||||
"Could not open the pad's audio interface. Some kernels " +
|
||||
"refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
},
|
||||
)
|
||||
}, "pf-pad-selftest-console").start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
|
||||
private class PadState {
|
||||
var deviceId = -1
|
||||
private var buttons = 0
|
||||
private var dpad = 0
|
||||
private var lx = 0
|
||||
private var ly = 0
|
||||
private var last: IntArray? = null
|
||||
|
||||
fun button(bit: Int, down: Boolean) {
|
||||
buttons = if (down) buttons or (1 shl bit) else buttons and (1 shl bit).inv()
|
||||
}
|
||||
|
||||
fun dpad(bit: Int, down: Boolean) {
|
||||
dpad = if (down) dpad or (1 shl bit) else dpad and (1 shl bit).inv()
|
||||
}
|
||||
|
||||
fun stick(x: Float, y: Float) {
|
||||
lx = (x.coerceIn(-1f, 1f) * 32767f).roundToInt()
|
||||
ly = (y.coerceIn(-1f, 1f) * 32767f).roundToInt()
|
||||
}
|
||||
|
||||
/** The HAT is the D-pad on most pads' motion path (±1 per axis). */
|
||||
fun hat(x: Float, y: Float) {
|
||||
dpad(2, x <= -0.5f); dpad(3, x >= 0.5f); dpad(0, y <= -0.5f); dpad(1, y >= 0.5f)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
buttons = 0; dpad = 0; lx = 0; ly = 0
|
||||
}
|
||||
|
||||
fun push(handle: Long) {
|
||||
val now = intArrayOf(buttons, lx, ly, dpad)
|
||||
if (last?.contentEquals(now) == true) return
|
||||
last = now
|
||||
NativeBridge.nativeConsolePadSample(handle, buttons, lx, ly, dpad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.console.ConsoleJson
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
|
||||
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
|
||||
*
|
||||
* They were written and read nested under an `"extra"` object instead. Serde put that whole
|
||||
* object into the map under the literal key `"extra"`, so no console row ever found
|
||||
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
|
||||
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
|
||||
* with nothing happening: the console stayed up, because the setting never moved.
|
||||
*/
|
||||
class ConsoleSettingsExtraTest {
|
||||
@Test
|
||||
fun androidKeysAreWrittenFlat() {
|
||||
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
|
||||
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
|
||||
assertFalse(j.getBoolean("android.low_latency"))
|
||||
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
|
||||
}
|
||||
|
||||
/** A store written by the nesting build must not keep echoing its dead wrapper. */
|
||||
@Test
|
||||
fun aStaleNestedWrapperIsDropped() {
|
||||
val base = JSONObject().put(
|
||||
"extra",
|
||||
JSONObject().put("android.gamepad_ui_enabled", true),
|
||||
)
|
||||
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theConsolesOwnSaveIsReadBack() {
|
||||
val saved = JSONObject()
|
||||
.put("android.gamepad_ui_enabled", false)
|
||||
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
|
||||
.put("android.ds_capture", false)
|
||||
val next = ConsoleJson.applySettings(Settings(), saved)
|
||||
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
|
||||
assertFalse(next.dsCapture)
|
||||
}
|
||||
|
||||
/** Both halves against each other — the shape only holds if they agree. */
|
||||
@Test
|
||||
fun theRoundTripKeepsEveryAndroidRow() {
|
||||
val want = Settings(
|
||||
gamepadUiEnabled = false,
|
||||
gamepadUiMode = GAMEPAD_UI_ALWAYS,
|
||||
lowLatencyMode = false,
|
||||
rumbleOnPhone = true,
|
||||
gyroOnPhone = true,
|
||||
sc2Capture = false,
|
||||
dsCapture = false,
|
||||
)
|
||||
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
|
||||
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
|
||||
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
|
||||
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
|
||||
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
|
||||
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
|
||||
assertEquals(want.sc2Capture, got.sc2Capture)
|
||||
assertEquals(want.dsCapture, got.dsCapture)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console route to the two sub-screens, driven through the REAL settings screen — the rows
|
||||
* themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip:
|
||||
* that a press on the row reaches the shell, and that coming back lands where you left rather than
|
||||
* at the top of the first section (the shell's `AnimatedContent` discards a screen's state the
|
||||
* moment it stops being the target, so the place has to travel out and back).
|
||||
*
|
||||
* Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path
|
||||
* needs a `MainActivity` for its probes, and both routes end in the same `activate`.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
|
||||
* the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class ConsoleSubScreenRoutesTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun openingTheControllersRowNavigatesAndReportsWhereItWas() {
|
||||
var opened = 0
|
||||
var place: GpSettingsPlace? = null
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
onOpenControllers = { opened++ },
|
||||
// Entering as if we had just come back from it, which is also what puts the cursor
|
||||
// on the row — so a single tap ACTIVATES rather than merely focusing.
|
||||
resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
onPlace = { place = it },
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Connected controllers").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals("the console never reached the diagnostics screen", 1, opened)
|
||||
assertEquals(
|
||||
"the place has to leave before the row does — this screen is gone the next frame",
|
||||
GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
place,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Back from a sub-screen lands on the section it was opened from, with the row on screen. The
|
||||
* cursor is restored by row ID rather than index, so it survives a section whose length follows
|
||||
* the hardware.
|
||||
*/
|
||||
@Test
|
||||
fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"),
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
// Not back at the top of the first section — "Resolution" leads the Stream tab, which is
|
||||
// where a screen that forgot its place would be.
|
||||
compose.onNodeWithText("Resolution").assertDoesNotExist()
|
||||
// And the legend describes THIS row's A. It said the literal "Pin to hosts" on every
|
||||
// non-adjustable row back when profiles were the only ones.
|
||||
compose.onNodeWithText("Open").assertIsDisplayed()
|
||||
compose.onNodeWithText("Pin to hosts").assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices screen stands on its own on the console's field: no Scaffold or Surface above it
|
||||
* (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it
|
||||
* is most of the assertion — a screen that only ever ran inside the touch Scaffold takes its
|
||||
* content colour from one.
|
||||
*/
|
||||
@Test
|
||||
fun theConsoleNoticesScreenStandsOnItsOwn() {
|
||||
compose.setContent { ConsoleLicensesScreen(onBack = {}) }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
compose.onNodeWithText("Scroll").assertIsDisplayed()
|
||||
compose.onNodeWithText("Close").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console's route to the two screens that were reachable from touch only — the
|
||||
* connected-controllers diagnostics and the open-source notices.
|
||||
*
|
||||
* "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS
|
||||
* the interface: there is no touch UI to fall back to, so a screen with no console row could not be
|
||||
* opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen.
|
||||
*/
|
||||
class ConsoleSubScreenRowsTest {
|
||||
|
||||
private fun rows(
|
||||
forwarding: Boolean = true,
|
||||
version: String = "1.2.3",
|
||||
controllers: () -> Unit = {},
|
||||
licenses: () -> Unit = {},
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
appVersion = version,
|
||||
openControllers = controllers,
|
||||
openLicenses = licenses,
|
||||
) {}
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow = rows.first { it.id == id }
|
||||
|
||||
@Test
|
||||
fun `the controllers row opens the diagnostics view from the controller section`() {
|
||||
var opened = 0
|
||||
val r = row(rows(controllers = { opened++ }), "controllers")
|
||||
assertEquals(GpTab.CONTROLLER, r.tab)
|
||||
assertEquals("Connected controllers", r.label)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/**
|
||||
* It must NOT follow the master forwarding switch, unlike every other row in its section: the
|
||||
* screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic
|
||||
* that dims itself when the thing it diagnoses is off is worse than no diagnostic.
|
||||
*/
|
||||
@Test
|
||||
fun `the controllers row stays live with forwarding off`() {
|
||||
val off = rows(forwarding = false)
|
||||
assertTrue(row(off, "controllers").enabled)
|
||||
assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" }))
|
||||
// Its neighbours in the section still dim, so this is a deliberate exemption and not a
|
||||
// forgotten `enabled =`.
|
||||
assertFalse(row(off, "sc2").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the about row opens the notices and states the installed version`() {
|
||||
var opened = 0
|
||||
val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses")
|
||||
assertEquals(GpTab.INTERFACE, r.tab)
|
||||
assertEquals("About", r.header)
|
||||
// The version rides in the value slot — on a TV this row is the whole About page.
|
||||
assertEquals("0.27.0", r.value)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */
|
||||
@Test
|
||||
fun `neither row steps a value`() {
|
||||
val all = rows()
|
||||
for (id in listOf("controllers", "licenses")) {
|
||||
val r = row(all, id)
|
||||
assertFalse("$id should draw no chevrons", r.adjustable)
|
||||
assertFalse("$id must refuse a step", r.adjust(1))
|
||||
assertFalse("$id must refuse a step", r.adjust(-1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable
|
||||
* row, because a profile row was the only kind there was — so the moment another one existed,
|
||||
* A on it was advertised as pinning something.
|
||||
*/
|
||||
@Test
|
||||
fun `an action row advertises what A actually does`() {
|
||||
val all = rows()
|
||||
assertEquals("Open", row(all, "controllers").actionHint)
|
||||
assertEquals("Open", row(all, "licenses").actionHint)
|
||||
val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {}
|
||||
assertEquals("Pin to hosts", profiles.first().actionHint)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll geometry both console sub-screens share. A wall of text has no focusable rows for
|
||||
* Compose to keep visible, so these screens move the scroll state themselves — and how far one
|
||||
* press travels is the whole of their feel.
|
||||
*/
|
||||
@Test
|
||||
fun `a page overlaps what you were reading and a step is shorter still`() {
|
||||
val viewport = 1000f
|
||||
val page = consoleScrollDelta(viewport, page = true, dir = 1)
|
||||
val step = consoleScrollDelta(viewport, page = false, dir = 1)
|
||||
assertTrue("a page that skips a whole screenful loses your place", page < viewport)
|
||||
assertTrue("a page has to be worth pressing", page > viewport / 2f)
|
||||
assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page)
|
||||
assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f)
|
||||
// Before the first layout there is no viewport: a press then moves nothing, rather than
|
||||
// scrolling by a fraction of zero and reading as a dead button on the way in.
|
||||
assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f)
|
||||
}
|
||||
}
|
||||
@@ -118,21 +118,8 @@ class ConsoleVectorsTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The section names, in order. The desktop console carries one tab this client does not —
|
||||
* Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings
|
||||
* with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than
|
||||
* leaving it out, so neither side has to red the other to be right.
|
||||
*/
|
||||
@Test
|
||||
fun tabNamesMatchTheSharedVectors() {
|
||||
val tabs = vectors.getJSONArray("tabs")
|
||||
val want = (0 until tabs.length())
|
||||
.map { tabs.getJSONObject(it) }
|
||||
.filterNot { it.optBoolean("desktop_only", false) }
|
||||
.map { it.getString("name") }
|
||||
assertEquals("console settings tabs", want, GpTab.entries.map { it.title })
|
||||
}
|
||||
// The tab-names assertion moved with the tabs: the Skia shell renders them, and its Rust twin
|
||||
// (`tab_names_match_the_shared_vectors` in pf-console-ui) pins them against the same file.
|
||||
|
||||
/**
|
||||
* The screen-transition contract. The easing is sampled rather than compared as Bézier
|
||||
|
||||
@@ -136,46 +136,9 @@ class GamepadPaletteTest {
|
||||
assertTrue(light.shadeScale < 0.5f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
|
||||
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(
|
||||
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
|
||||
) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
assertTrue(rows.none { it.tab == GpTab.PROFILES })
|
||||
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
|
||||
assertTrue("$t is empty", rows.any { it.tab == t })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
assertEquals("Violet", palette().value)
|
||||
assertTrue("already the first = thud", !palette().adjust(-1))
|
||||
assertTrue(palette().adjust(1))
|
||||
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
|
||||
|
||||
// A from the last entry wraps home.
|
||||
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
|
||||
palette().activate()
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
// A store written by a newer client shows the palette that is actually drawing.
|
||||
s = s.copy(uiPalette = "chartreuse")
|
||||
assertEquals("Violet", palette().value)
|
||||
}
|
||||
// The settings-rows tests that lived here pinned the Compose console's row
|
||||
// catalog (`buildSettingsRows`). That console is gone — the shared Skia shell owns the rows
|
||||
// now, pinned in Rust (`pf-console-ui/src/screens/settings.rs`).
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console settings list must not MOVE under the cursor. This is the regression net for the
|
||||
* layout instability the visual refresh fixed, and it needs the real Compose runtime because the
|
||||
* bug was entirely a layout one — every value the model held was correct throughout.
|
||||
*
|
||||
* What used to happen: the focused row unfolded its description in place
|
||||
* (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew
|
||||
* another and shifted every row below the focus point — on a list that is simultaneously being
|
||||
* scrolled to keep the focused row visible, whose target therefore moved mid-animation. The
|
||||
* description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and
|
||||
* cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every
|
||||
* step, walking the ‹ chevron and the label's right edge back and forth.
|
||||
*
|
||||
* Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input
|
||||
* probes, and the screen routes both to the same `focus` state — the geometry under test is the
|
||||
* same either way.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36
|
||||
* while the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class GamepadSettingsLayoutTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun settings() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {})
|
||||
}
|
||||
}
|
||||
|
||||
/** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */
|
||||
private fun assertSame(what: String, expected: Dp, actual: Dp) {
|
||||
assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below
|
||||
* the new focus are the ones the old in-row detail pushed around, so they are the assertion
|
||||
* that matters; the row above proves the shrink half.
|
||||
*/
|
||||
@Test
|
||||
fun focusingARowMovesNoOtherRow() {
|
||||
settings()
|
||||
// Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and
|
||||
// "Compositor" sits below both candidates.
|
||||
val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
|
||||
// One tap on an unfocused row focuses it (a second would activate it — see the screen).
|
||||
compose.onNodeWithText("Bitrate").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top)
|
||||
assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stepping a value leaves the row's own geometry alone. The label's right edge is the probe:
|
||||
* it is what the widening value slot used to shove, and it is stable for any value that fits
|
||||
* the slot (which every shipped Bitrate label does).
|
||||
*/
|
||||
@Test
|
||||
fun steppingAValueMovesNoLabel() {
|
||||
settings()
|
||||
compose.onNodeWithText("Bitrate").performClick() // focus it
|
||||
compose.waitForIdle()
|
||||
val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
|
||||
compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value
|
||||
compose.waitForIdle()
|
||||
|
||||
val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left)
|
||||
assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right)
|
||||
assertSame("row changed height under a value step", labelBefore.top, labelAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused row's description is on screen — in the floating band, not inside the row. Proves
|
||||
* the detail did not simply get dropped when it left the row: it is still what the cursor
|
||||
* explains itself with.
|
||||
*/
|
||||
@Test
|
||||
fun theFocusedRowsDetailIsShown() {
|
||||
settings()
|
||||
compose.onNodeWithText("Refresh rate").performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
|
||||
* governed row is inert rather than merely dim.
|
||||
*
|
||||
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
|
||||
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
|
||||
* these tests pin both halves — the flag AND the refusal to write.
|
||||
*/
|
||||
class GamepadSettingsRowsTest {
|
||||
|
||||
/** Rows for a given forwarding state, capturing whatever a row writes back. */
|
||||
private fun rows(
|
||||
forwarding: Boolean,
|
||||
sink: MutableList<Settings> = mutableListOf(),
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow =
|
||||
rows.first { it.id == id }
|
||||
|
||||
/** Every row that only means something while a controller is actually being forwarded. */
|
||||
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
|
||||
|
||||
@Test
|
||||
fun `forwarding off dims every row that depends on it`() {
|
||||
val off = rows(forwarding = false)
|
||||
for (id in governed) {
|
||||
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
|
||||
}
|
||||
// The master switch itself stays live — otherwise it could never be turned back on.
|
||||
assertTrue(row(off, "padForward").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forwarding on leaves them all live`() {
|
||||
val on = rows(forwarding = true)
|
||||
for (id in governed) {
|
||||
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val off = rows(forwarding = false, sink = writes)
|
||||
for (id in governed) {
|
||||
val i = off.indexOfFirst { it.id == id }
|
||||
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
|
||||
// What the screen actually does on left/right/A — the whole point is that it no-ops.
|
||||
liveRow(off, i)?.adjust(1)
|
||||
liveRow(off, i)?.adjust(-1)
|
||||
liveRow(off, i)?.activate()
|
||||
}
|
||||
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same rows do write once forwarding is on`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val on = rows(forwarding = true, sink = writes)
|
||||
val i = on.indexOfFirst { it.id == "sc2" }
|
||||
assertNotNull(liveRow(on, i))
|
||||
liveRow(on, i)?.activate()
|
||||
assertEquals(1, writes.size)
|
||||
assertFalse("activate flips the toggle", writes[0].sc2Capture)
|
||||
}
|
||||
|
||||
/**
|
||||
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
|
||||
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
|
||||
*/
|
||||
@Test
|
||||
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
|
||||
val on = rows(forwarding = true)
|
||||
val ids = on.map { it.id }
|
||||
assertTrue("dsCapture row is missing", "dsCapture" in ids)
|
||||
assertEquals(
|
||||
"the two passthrough rows belong side by side",
|
||||
ids.indexOf("sc2") + 1,
|
||||
ids.indexOf("dsCapture"),
|
||||
)
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
|
||||
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
|
||||
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
|
||||
* be one last thing to step past on the way out.
|
||||
*/
|
||||
@Test
|
||||
fun `the activation-mode row follows the switch it belongs to`() {
|
||||
fun ids(enabled: Boolean) = buildSettingsRows(
|
||||
Settings(gamepadUiEnabled = enabled),
|
||||
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) {}.map { it.id }
|
||||
|
||||
val on = ids(enabled = true)
|
||||
assertTrue("the mode row is missing", "gamepadUIMode" in on)
|
||||
assertEquals(
|
||||
"the mode belongs directly under the switch it qualifies",
|
||||
on.indexOf("gamepadUI") + 1,
|
||||
on.indexOf("gamepadUIMode"),
|
||||
)
|
||||
val off = ids(enabled = false)
|
||||
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
|
||||
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
|
||||
}
|
||||
|
||||
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
|
||||
@Test
|
||||
fun `the activation-mode row steps the shared key`() {
|
||||
var s = Settings()
|
||||
fun mode() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }.first { it.id == "gamepadUIMode" }
|
||||
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertEquals("With a controller", mode().value)
|
||||
assertFalse("already the first = thud", mode().adjust(-1))
|
||||
assertTrue(mode().adjust(1))
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
|
||||
// A from the last entry wraps home.
|
||||
mode().activate()
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console home's tile list ([buildHomeTiles]). Pure JVM — the carousel itself needs the live
|
||||
* JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and
|
||||
* both are exactly the kind of thing that survives a refactor looking fine and behaving wrong.
|
||||
*
|
||||
* Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`.
|
||||
*/
|
||||
class HomeTilesTest {
|
||||
private fun host(
|
||||
name: String,
|
||||
address: String,
|
||||
fp: String = "",
|
||||
profileId: String? = null,
|
||||
pins: List<String> = emptyList(),
|
||||
) = KnownHost(
|
||||
address = address,
|
||||
port = 9777,
|
||||
name = name,
|
||||
fpHex = fp,
|
||||
paired = true,
|
||||
id = "id-$name",
|
||||
profileId = profileId,
|
||||
pinnedProfileIds = pins,
|
||||
)
|
||||
|
||||
private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost(
|
||||
key = "$address:9777",
|
||||
name = name,
|
||||
host = address,
|
||||
port = 9777,
|
||||
fingerprint = fp,
|
||||
)
|
||||
|
||||
private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6")
|
||||
private val travel = StreamProfile(id = "p-travel", name = "Travel")
|
||||
|
||||
/** The builder with nothing plugged in — every list empty, every callback a no-op. */
|
||||
private fun tiles(
|
||||
savedHosts: List<KnownHost> = emptyList(),
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
pins: Map<String, List<StreamProfile>> = emptyMap(),
|
||||
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
|
||||
online: Set<String> = emptySet(),
|
||||
onConnect: (KnownHost, String?) -> Unit = { _, _ -> },
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit = {},
|
||||
onAddHost: () -> Unit = {},
|
||||
) = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = { kh -> pins[kh.id].orEmpty() },
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.name in online },
|
||||
onConnect = onConnect,
|
||||
onConnectDiscovered = onConnectDiscovered,
|
||||
onAddHost = onAddHost,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is
|
||||
* reached by walking one tile past its host, and a builder that grouped all the pins at the end
|
||||
* would still LOOK right in a screenshot of any single tile.
|
||||
*/
|
||||
@Test
|
||||
fun pinnedCardsFollowTheirOwnHost() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id))
|
||||
val studio = host("studio", "192.168.1.61", pins = listOf(work.id))
|
||||
val ids = tiles(
|
||||
savedHosts = listOf(living, studio),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)),
|
||||
).map { it.id }
|
||||
assertEquals(
|
||||
listOf(
|
||||
"saved-id-living",
|
||||
"pin-id-living-p-work",
|
||||
"pin-id-living-p-travel",
|
||||
"saved-id-studio",
|
||||
"pin-id-studio-p-work",
|
||||
"add",
|
||||
),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
/** Add Host is the last tile, always — including on a device with nothing saved or seen. */
|
||||
@Test
|
||||
fun theAddTileIsAlwaysLast() {
|
||||
val empty = tiles()
|
||||
assertEquals(listOf("add"), empty.map { it.id })
|
||||
assertTrue(empty.single().isAdd)
|
||||
|
||||
val populated = tiles(
|
||||
savedHosts = listOf(host("living", "192.168.1.42")),
|
||||
discoveredUnsaved = listOf(advert("studio", "192.168.1.61")),
|
||||
)
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id })
|
||||
assertTrue(populated.last().isAdd)
|
||||
// The Add tile is not a host: no library, no options menu, nothing to wake.
|
||||
assertNull(populated.last().knownHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* A host that is both saved and advertising appears ONCE. The de-dupe is the caller's
|
||||
* ([KnownHost.matches], which the screen applies before handing the list over) — checked here
|
||||
* because the rule that matters is the fingerprint one: a host that came back on a new DHCP
|
||||
* address is the same machine, and matching on address alone would offer it a second time as a
|
||||
* stranger, next to the record that already holds its trust.
|
||||
*/
|
||||
@Test
|
||||
fun aSavedHostSeenOnTheNetworkIsNotListedTwice() {
|
||||
val fp = "ab12cd34"
|
||||
val living = host("living", "192.168.1.42", fp = fp)
|
||||
// Same host, new address after a cold boot, plus a genuine stranger.
|
||||
val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99"))
|
||||
val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } }
|
||||
val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id }
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip says which profile a press will connect with — the host's binding on its own tile,
|
||||
* the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only
|
||||
* thing standing between a user and a stream with settings they didn't choose.
|
||||
*/
|
||||
@Test
|
||||
fun theChipNamesTheProfileThePressWillUse() {
|
||||
val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(travel)),
|
||||
)
|
||||
val own = result[0]
|
||||
assertEquals("Work", own.profileName)
|
||||
assertEquals(Color(0xFF3B82F6), own.profileAccent)
|
||||
assertNull(own.pinnedProfileId)
|
||||
|
||||
val pin = result[1]
|
||||
assertEquals("Travel", pin.profileName)
|
||||
assertEquals(travel.id, pin.pinnedProfileId)
|
||||
// Travel set no accent: a chip with no colour, not a crash and not a stray default.
|
||||
assertNull(pin.profileAccent)
|
||||
|
||||
// A binding whose profile was deleted resolves to nothing — the tile stays silent rather
|
||||
// than naming an id that resolves to nobody.
|
||||
val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone")))
|
||||
assertNull(dangling[0].profileName)
|
||||
}
|
||||
|
||||
/** Both address and the subtitle: a pin card says where it points, like every other card. */
|
||||
@Test
|
||||
fun everySavedTileSaysWhereItPoints() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
online = setOf("living"),
|
||||
)
|
||||
result.take(2).forEach {
|
||||
assertEquals("192.168.1.42:9777", it.subtitle)
|
||||
assertEquals("living", it.title)
|
||||
assertTrue(it.filled)
|
||||
assertTrue(it.online)
|
||||
assertTrue(it.paired)
|
||||
assertNotNull(it.knownHost)
|
||||
}
|
||||
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
|
||||
// the pinned profile — the library is a way to start a card, not a host-level action.
|
||||
assertTrue(result[0].hasLibrary)
|
||||
assertTrue(result[1].hasLibrary)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press DOES. A host's own tile dials with no one-off reference so the host's binding is
|
||||
* followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the
|
||||
* other way round) is invisible until someone streams at the wrong bitrate.
|
||||
*/
|
||||
@Test
|
||||
fun activationCarriesTheRightProfileReference() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val dialled = mutableListOf<Pair<String, String?>>()
|
||||
val discovered = mutableListOf<String>()
|
||||
var addOpened = false
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")),
|
||||
onConnect = { kh, oneOff -> dialled += kh.name to oneOff },
|
||||
onConnectDiscovered = { dh -> discovered += dh.host },
|
||||
onAddHost = { addOpened = true },
|
||||
)
|
||||
result.forEach { it.activate() }
|
||||
assertEquals(listOf("living" to null, "living" to work.id), dialled)
|
||||
assertEquals(listOf("192.168.1.99"), discovered)
|
||||
assertTrue(addOpened)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class ProfilesTest {
|
||||
hdrEnabled = false,
|
||||
compositor = 4,
|
||||
audioChannels = 6,
|
||||
audioFormat = AUDIO_FORMAT_LOSSLESS_96,
|
||||
micEnabled = true,
|
||||
touchMode = TouchMode.POINTER,
|
||||
mouseMode = MouseMode.CAPTURE,
|
||||
@@ -67,6 +68,7 @@ class ProfilesTest {
|
||||
assertFalse(out.hdrEnabled)
|
||||
assertEquals(4, out.compositor)
|
||||
assertEquals(6, out.audioChannels)
|
||||
assertEquals(AUDIO_FORMAT_LOSSLESS_96, out.audioFormat)
|
||||
assertTrue(out.micEnabled)
|
||||
assertEquals(TouchMode.POINTER, out.touchMode)
|
||||
assertEquals(MouseMode.CAPTURE, out.mouseMode)
|
||||
@@ -236,6 +238,125 @@ class ProfilesTest {
|
||||
assertEquals(base, made.first().overrides.apply(base))
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio-format setting is a STRING, and the two numbers it turns into are what the `Hello`
|
||||
* carries — get the mapping wrong and the session either spends 8.5 Mbps it was not asked for
|
||||
* or silently declines to ask for what it was. The Opus row is the load-bearing one: it must
|
||||
* be the `0`/`0` "did not ask" sentinel, because core sets `CLIENT_CAP_AUDIO_HIRES` on ANY
|
||||
* non-zero field — see [theOpusSettingDoesNotAdvertiseTheLosslessCapability].
|
||||
*/
|
||||
@Test
|
||||
fun theAudioFormatSettingMapsToTheWireFieldsItClaims() {
|
||||
assertEquals(
|
||||
AUDIO_FORMAT_WIRE_UNSPECIFIED,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_OPUS).audioFormatWire(),
|
||||
)
|
||||
// Both rate families. The 44.1 one was deferred only for as long as the shared jitter
|
||||
// policy divided by 1 000 before it multiplied (44 100 → 44 samples/ms, every buffer
|
||||
// figure 2.3 % out); core multiplies first now, so these are simply rates.
|
||||
assertEquals(
|
||||
44_100 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_441).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
48_000 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_48).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
88_200 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_882).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
96_000 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_96).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
176_400 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_1764).audioFormatWire(),
|
||||
)
|
||||
// The default is the legacy request — a fresh install asks for exactly what it always did.
|
||||
assertEquals(AUDIO_FORMAT_WIRE_UNSPECIFIED, Settings().audioFormatWire())
|
||||
// A newer build's value (or a corrupted pref) falls back to Opus rather than reaching the
|
||||
// host as an unrepresentable rate: a settings string must never be able to block a connect.
|
||||
assertEquals(
|
||||
AUDIO_FORMAT_WIRE_UNSPECIFIED,
|
||||
base.copy(audioFormat = "lossless192").audioFormatWire(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠⚠ **A user who chose Standard (Opus) must not advertise `CLIENT_CAP_AUDIO_HIRES`**, and the
|
||||
* only thing standing between them and 1.5 Mbps of PCM they did not ask for is that this pair
|
||||
* is `0`/`0`.
|
||||
*
|
||||
* Core's `advertised_client_caps` sets the bit when EITHER field is non-zero — it keys on "a
|
||||
* format was specified", not on "the format differs from the default", because 48 kHz/16-bit is
|
||||
* both the legacy pair AND the cheapest lossless rung and the other rule would make that rung
|
||||
* unrequestable. The host's gate then accepts 48 kHz/16-bit as a perfectly supported format. So
|
||||
* a client that sends the legacy-looking numbers as its stand-in for "default" opts every one of
|
||||
* its users in, on every host that has not deliberately opted out — which since 2026-08-17 is
|
||||
* every host, `PUNKTFUNK_AUDIO_HIRES` having gone default-ON — with no surface anywhere saying
|
||||
* so: a declined session and a silently granted one look identical from the settings screen.
|
||||
*
|
||||
* This client did exactly that until the four clients were compared. The rule is restated here
|
||||
* rather than reached through core because Kotlin cannot call it; core's own tests pin the other
|
||||
* half.
|
||||
*/
|
||||
@Test
|
||||
fun theOpusSettingDoesNotAdvertiseTheLosslessCapability() {
|
||||
// Core's rule, verbatim: `audio_rate_hz != 0 || audio_bits != 0`.
|
||||
fun asksForHiRes(wire: Pair<Int, Int>) = wire.first != 0 || wire.second != 0
|
||||
|
||||
assertFalse(asksForHiRes(base.copy(audioFormat = AUDIO_FORMAT_OPUS).audioFormatWire()))
|
||||
assertFalse(asksForHiRes(Settings().audioFormatWire()))
|
||||
assertFalse(asksForHiRes(base.copy(audioFormat = "lossless192").audioFormatWire()))
|
||||
// …and every row that IS a lossless choice must ask, or the setting does nothing at all.
|
||||
// That asymmetry is the whole contract.
|
||||
for ((value, _) in AUDIO_FORMAT_OPTIONS.drop(1)) {
|
||||
assertTrue(value, asksForHiRes(base.copy(audioFormat = value).audioFormatWire()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored values are a CROSS-CLIENT contract, shared verbatim with the Apple client's
|
||||
* `AudioFormatChoice` raw values and the desktop `AUDIO_FORMATS`. A profile carries the key
|
||||
* through untouched, so a rename here does not break a round trip loudly — it breaks it
|
||||
* silently, by leaving the other client to fall back to its own global default on a profile
|
||||
* that looks like it applied. Spelled out as literals rather than referenced through the
|
||||
* constants, because a test that reads the constant cannot detect the constant changing.
|
||||
*
|
||||
* The naming rule for anything added later is the kHz figure with the decimal point dropped.
|
||||
*/
|
||||
@Test
|
||||
fun theStoredAudioFormatValuesAreTheOnesEveryOtherClientStores() {
|
||||
assertEquals("opus", AUDIO_FORMAT_OPUS)
|
||||
assertEquals("lossless441", AUDIO_FORMAT_LOSSLESS_441)
|
||||
assertEquals("lossless48", AUDIO_FORMAT_LOSSLESS_48)
|
||||
assertEquals("lossless882", AUDIO_FORMAT_LOSSLESS_882)
|
||||
assertEquals("lossless96", AUDIO_FORMAT_LOSSLESS_96)
|
||||
assertEquals("lossless1764", AUDIO_FORMAT_LOSSLESS_1764)
|
||||
// Opus first (the default), then the lossless rows by ascending rate.
|
||||
assertEquals(
|
||||
listOf(
|
||||
AUDIO_FORMAT_OPUS,
|
||||
AUDIO_FORMAT_LOSSLESS_441,
|
||||
AUDIO_FORMAT_LOSSLESS_48,
|
||||
AUDIO_FORMAT_LOSSLESS_882,
|
||||
AUDIO_FORMAT_LOSSLESS_96,
|
||||
AUDIO_FORMAT_LOSSLESS_1764,
|
||||
),
|
||||
AUDIO_FORMAT_OPTIONS.map { it.first },
|
||||
)
|
||||
// Every offered row resolves to a DISTINCT request — a duplicate would be a menu entry the
|
||||
// wire cannot tell from its neighbour — every lossless one is 24-bit, and exactly one row
|
||||
// (Opus, the first) is the "did not ask" sentinel.
|
||||
val wire = AUDIO_FORMAT_OPTIONS.map { base.copy(audioFormat = it.first).audioFormatWire() }
|
||||
assertEquals(wire.size, wire.toSet().size)
|
||||
assertTrue(wire.drop(1).all { it.second == 24 })
|
||||
assertEquals(1, wire.count { it == AUDIO_FORMAT_WIRE_UNSPECIFIED })
|
||||
assertEquals(AUDIO_FORMAT_WIRE_UNSPECIFIED, wire.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintedIdsAreWellFormed() {
|
||||
val id = newProfileId()
|
||||
|
||||
@@ -30,10 +30,19 @@ class StatsOverlayAudioTest {
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
/**
|
||||
* A plausible 35-double window with the audio gauges dialled in. Everything before 33 is the
|
||||
* DETAILED-renderable shape the ShotScenes fixture uses; only the last two matter here.
|
||||
* A plausible 38-double window with the audio gauges dialled in. Everything before 33 is the
|
||||
* DETAILED-renderable shape the ShotScenes fixture uses; only the tail matters here. The
|
||||
* format triple (35–37) defaults to an ordinary Opus session, so a test that says nothing
|
||||
* about it is asserting against the shape every session has always had.
|
||||
*/
|
||||
private fun stats(bufferMs: Double, avOffsetMs: Double, size: Int = 35): DoubleArray {
|
||||
private fun stats(
|
||||
bufferMs: Double,
|
||||
avOffsetMs: Double,
|
||||
size: Int = 38,
|
||||
codec: Double = 0.0,
|
||||
rateHz: Double = 48_000.0,
|
||||
bits: Double = 16.0,
|
||||
): DoubleArray {
|
||||
val full = doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
@@ -42,6 +51,7 @@ class StatsOverlayAudioTest {
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
0.1, 0.3, 0.0,
|
||||
bufferMs, avOffsetMs,
|
||||
codec, rateHz, bits,
|
||||
)
|
||||
return full.copyOf(size)
|
||||
}
|
||||
@@ -91,4 +101,69 @@ class StatsOverlayAudioTest {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0, size = 33))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The RESOLVED audio format (35–37) — the surface `design/hi-res-audio.md` §10 requires, and
|
||||
* the only one that can answer "did lossless actually happen". The settings screen shows what
|
||||
* this device REQUESTED; the host's gate (its own switch off by default) can decline every
|
||||
* one of them and the session then looks, sounds and measures exactly like a granted one.
|
||||
* Codec `2` is the `0xD3` lossless plane.
|
||||
*/
|
||||
@Test
|
||||
fun aLosslessSessionNamesTheFormatItResolved() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 96_000.0, bits = 24.0))
|
||||
compose.onNodeWithText("audio lossless 96 kHz / 24-bit").assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The 44.1 kHz family renders as kHz with a tenth, not as raw Hz.
|
||||
*
|
||||
* The rest of the HUD can print whatever reads well; this line cannot, because its entire job
|
||||
* is to be compared at a glance with the settings row that asked for the format. While the
|
||||
* ladder was 48/96 only every rate divided by a thousand and the fallback arm was unreachable;
|
||||
* admitting 44 100 / 88 200 / 176 400 made it the arm half the menu now takes, and "44100 Hz"
|
||||
* next to a menu saying "44.1 kHz" is one more thing for a reader to have to work out.
|
||||
*/
|
||||
@Test
|
||||
fun aFractionalRateRendersInKilohertzRatherThanRawHertz() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 44_100.0, bits = 24.0))
|
||||
compose.onNodeWithText("audio lossless 44.1 kHz / 24-bit").assertExists()
|
||||
}
|
||||
|
||||
/** …and the top of the ladder, which is the row most likely to have been declined outright. */
|
||||
@Test
|
||||
fun theTopOfTheLadderNamesItselfExactly() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 176_400.0, bits = 24.0))
|
||||
compose.onNodeWithText("audio lossless 176.4 kHz / 24-bit").assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown from NORMAL, unlike every other audio figure: a user who paid 4.6 Mbps for this should
|
||||
* not have to find the DETAILED tier to learn whether they got it.
|
||||
*/
|
||||
@Test
|
||||
fun theFormatLineIsNotReservedForTheDetailedTier() {
|
||||
show(
|
||||
stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 48_000.0, bits = 24.0),
|
||||
verbosity = StatsVerbosity.NORMAL,
|
||||
)
|
||||
compose.onNodeWithText("audio lossless 48 kHz / 24-bit").assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent for Opus — which is every session anyone who never touched the setting will ever run,
|
||||
* so a line stating it would be noise on almost every HUD. Absence IS the ordinary case.
|
||||
*/
|
||||
@Test
|
||||
fun anOpusSessionSaysNothingAboutTheFormat() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio lossless", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** An older native lib emits 35 doubles; the format line must be omitted, never mis-indexed. */
|
||||
@Test
|
||||
fun aPreFormatNativeLayoutOmitsTheFormatLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, size = 35, codec = 2.0))
|
||||
compose.onNodeWithText("audio lossless", substring = true).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
+7
-68
@@ -127,57 +127,9 @@ class ScreenshotTest {
|
||||
WakeTimedOutScene()
|
||||
}
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
|
||||
* on the right instead of the floating band.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
* show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
* drifting blobs — an honest approximation rather than an emulation, and the thing this shot
|
||||
* exists to keep honest.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
// The licences view — the one screen the console still opens as a Compose takeover. Shot on a
|
||||
// dark AND a pale palette, because the console draws it through a ColorScheme derived from the
|
||||
// palette's ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@@ -185,9 +137,6 @@ class ScreenshotTest {
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
@@ -196,23 +145,13 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the
|
||||
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
|
||||
* The same shelf as the TOUCH grid — the presentation a finger gets from a host card's
|
||||
* "Browse library…". Portrait (the default qualifiers), because that is the orientation a
|
||||
* phone browses a poster wall in, and the one whose column count the layout has to get right.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
fun libraryTouch() = shootRoot("library-touch") { TouchLibraryScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
|
||||
@@ -26,10 +26,13 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.BatteryFull
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.SignalCellular4Bar
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -51,9 +54,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import coil.ImageLoader
|
||||
@@ -61,13 +62,11 @@ import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.ControllersScreen
|
||||
import io.unom.punktfunk.Coverflow
|
||||
import io.unom.punktfunk.GamepadAuroraBackground
|
||||
import io.unom.punktfunk.TouchGrid
|
||||
import io.unom.punktfunk.GamepadHintBar
|
||||
import io.unom.punktfunk.PadGlyph
|
||||
import io.unom.punktfunk.PadInfo
|
||||
@@ -77,8 +76,6 @@ import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.library.Artwork
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
import io.unom.punktfunk.LocalGamepadPalette
|
||||
import io.unom.punktfunk.Settings
|
||||
@@ -333,7 +330,6 @@ internal fun SettingsProfileScene() {
|
||||
@Composable
|
||||
internal fun SpeedTestScene() {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi = false,
|
||||
hostName = "Living Room PC",
|
||||
target = SpeedTestTarget.Ask(newProfile("Game")),
|
||||
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
|
||||
@@ -446,12 +442,12 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// The full 35-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
|
||||
// The full 38-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
|
||||
// authoritative index list: [fps, mbps, e2eP50, e2eP95, latValid, skew, w, h, hz,
|
||||
// lostTotal, bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50,
|
||||
// decodeP50, hostP50, netP50, lost, skipped, fec, frames, dispValid, displayP50,
|
||||
// e2eDispP50, e2eDispP95, paceP50, latchP50, presents, presenterActive, feedP50, codecP50,
|
||||
// skippedOverflow, audioBufferMs, audioAvOffsetMs].
|
||||
// skippedOverflow, audioBufferMs, audioAvOffsetMs, audioCodec, audioRateHz, audioBits].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
|
||||
@@ -475,6 +471,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
// The audio plane: a 28 ms ring placed 4 ms behind the picture — a converged sync
|
||||
// loop, i.e. inside the deadband it deliberately leaves alone.
|
||||
28.0, 4.0,
|
||||
// The resolved audio format: codec 0 = Opus at 48 kHz/16-bit, which is what an
|
||||
// ordinary session runs and what these shots are of. The HUD's format line only
|
||||
// renders for the lossless plane (codec 2), so this triple deliberately adds
|
||||
// nothing to the capture — the scene shows the shape almost every user sees.
|
||||
0.0, 48_000.0, 16.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
@@ -504,14 +505,6 @@ internal fun WakingScene() =
|
||||
internal fun WakeTimedOutScene() =
|
||||
ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The console / gamepad connect flow (the real full-screen [ConnectTakeover]) — the aurora backdrop
|
||||
* with a bottom hint bar, the same signature look the console home uses.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The real console settings screen — the section tab strip, the glass rows, the focused row's
|
||||
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
|
||||
@@ -549,74 +542,40 @@ internal fun StreamBannerScene(pad: Boolean) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The console HOME — the host carousel over the living backdrop, which is the screen the aurora is
|
||||
* most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic
|
||||
* MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob
|
||||
* fallback, and the two are only comparable side by side. The scene composes [GamepadHome]
|
||||
* directly with mock tiles — it needs no JNI core and no session, unlike the ConnectScreen that
|
||||
* normally feeds it.
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
val tiles = listOf(
|
||||
HomeTile(
|
||||
id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired",
|
||||
filled = true, online = true, paired = true, activate = {},
|
||||
),
|
||||
HomeTile(
|
||||
id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered",
|
||||
online = true, activate = {},
|
||||
),
|
||||
HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
libraryEnabled = true,
|
||||
controllerName = "Xbox Wireless Controller",
|
||||
navActive = false,
|
||||
onActivate = {},
|
||||
onOpenLibrary = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
* The one Compose screen the console still opens over itself — the open-source notices — in its
|
||||
* console presentation. (Connected controllers used to be its sibling here; it is the console's
|
||||
* own Skia screen now, covered by pf-console-ui's tests.)
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* Worth a shot, and worth a PALE one: it is an ordinary Material screen underneath, and the
|
||||
* console shows it through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Its touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
* The controllers screen with [shotPads] injected — Robolectric enumerates no input devices, and
|
||||
* the connected-pad card is the point of the shot. Wrapped in a background [Surface]: the
|
||||
* activity provides the dark ground in the app, and without one here the content color falls
|
||||
* back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
@@ -641,6 +600,14 @@ internal fun AddHostScene() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// The Compose console's scenes (home carousel, settings, coverflow, connect takeover) are gone
|
||||
// with the screens themselves: the console is the Skia shell now
|
||||
// (design/android-skia-console-port.md), which renders over native GL and cannot compose under
|
||||
// Roborazzi. Its store shots come from the desktop screenshot dump (the same pixels) or from a
|
||||
// device capture. The scenes that remain are the touch UI and the two Compose platform screens
|
||||
// the console still opens (Controllers, Licences).
|
||||
|
||||
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
|
||||
internal fun shotPads() = listOf(
|
||||
PadInfo(
|
||||
@@ -663,39 +630,34 @@ internal fun shotPads() = listOf(
|
||||
* prove nothing at all.
|
||||
*/
|
||||
/**
|
||||
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
|
||||
* The library screen itself can't be shot — its state comes off the network — so the scene rebuilds
|
||||
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
|
||||
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
|
||||
* posters, so the frozen animation clock never races an async load.
|
||||
* The TOUCH library — the poster grid a finger reaches through a host card's "Browse library…",
|
||||
* with the same mock shelf the coverflow scene uses. Same construction as [LibraryScene]: the real
|
||||
* [TouchGrid] under a rebuilt header, because the screen around it takes its state off the network.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
|
||||
internal fun TouchLibraryScene() {
|
||||
val context = LocalContext.current
|
||||
val loader = remember { shotLibraryLoader(context) }
|
||||
val games = remember { shotGames() }
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ConsoleHeader("Living Room PC — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Coverflow(games, loader, navActive = false, onLaunch = {})
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = {}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Text(
|
||||
"Living Room PC — Library",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = {}) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Reload")
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
TouchGrid(games, loader, onLaunch = {}, onCopyLink = {}, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -956,29 +918,3 @@ private fun drawEmber(canvas: Canvas) {
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
|
||||
// The scene calls the screen directly, so it has to publish the palette locals `App` would
|
||||
// normally provide — without them a light palette would render with the default DARK ink and
|
||||
// the shot would silently prove nothing.
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadSettingsScreen(
|
||||
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,20 +40,4 @@ class TvScreenshotTest {
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
|
||||
@Test
|
||||
fun library() = shootRoot("library") { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
|
||||
@@ -90,6 +90,32 @@ fun Exec.cargoNdkEnvironment() {
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
// The Skia console (pf-console-ui over skia-bindings): prebuilt Skia archives are keyed by
|
||||
// target + features. rust-skia's GitHub releases carry no armv7-linux-androideabi archive, so
|
||||
// ALL three Android keys are served from our own release — public, R2-backed, unauthenticated:
|
||||
// https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz
|
||||
// (the armv7 archive built by us, the two 64-bit ones byte-for-byte mirrors of rust-skia's —
|
||||
// design/android-skia-console-port.md WP6; re-derive + re-upload on every skia-safe bump).
|
||||
// Override with `-PskiaBinariesUrl=<template>` or the `SKIA_BINARIES_URL` env (`{tag}`/`{key}`
|
||||
// placeholders, `file://` allowed) — e.g. a local mirror while cutting the next bump's archives.
|
||||
// 🛑 skia-bindings never fails when no archive matches — it silently builds Skia from source
|
||||
// for hours; every log must show `DOWNLOAD AND INSTALL SUCCEEDED` per target.
|
||||
//
|
||||
// The archives at rust-skia tag 0.99.0 (skia hash a25a0fdb7d90429aa2d1), key
|
||||
// `<target>-gl-jpegd-jpege-pdf-textlayout`, sha256 — re-derive on every skia-safe bump:
|
||||
// aarch64-linux-android fdbb25dd2e4ff22ce663b38d368ea696c88a522c73f54010662046b26bcf362c (GitHub)
|
||||
// x86_64-linux-android 93c1eaf379f539565343e99fdac4414fa22e45daa04de689bb0db2ef9290523b (GitHub)
|
||||
// armv7-linux-androideabi 4867856bcd1f01c197f796346ba555cffddb5e151f6cd072663ec1a56983d685 (ours:
|
||||
// FORCE_SKIA_BUILD=1 cargo ndk -t armeabi-v7a build -p pf-console-ui --no-default-features,
|
||||
// then OUT_DIR/skia/{libskia,libskshaper,libskparagraph,libskunicode_core,libskunicode_icu,
|
||||
// libskia-bindings}.a + bindings.rs + tag.txt + key.txt packed as skia-binaries/ in
|
||||
// skia-binaries-<key>.tar.gz)
|
||||
environment(
|
||||
"SKIA_BINARIES_URL",
|
||||
(project.findProperty("skiaBinariesUrl") as String? ?: System.getenv("SKIA_BINARIES_URL"))
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz",
|
||||
)
|
||||
}
|
||||
|
||||
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -127,8 +128,12 @@ object Gamepad {
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
|
||||
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
|
||||
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
|
||||
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -188,9 +193,53 @@ object Gamepad {
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
|
||||
fun pads(): List<InputDevice> =
|
||||
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
|
||||
/**
|
||||
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
|
||||
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
|
||||
*
|
||||
* [isPad] alone answers "did this event come from a pad source", which is the right question
|
||||
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
|
||||
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
|
||||
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
|
||||
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
|
||||
* "With a controller" has no way back to the touch UI.
|
||||
*
|
||||
* The capability probe is what separates them: a source class is a claim, a stick or a face
|
||||
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
|
||||
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
|
||||
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
|
||||
* device), which is worth excluding but catches none of the above.
|
||||
*/
|
||||
fun looksLikeController(dev: InputDevice?): Boolean {
|
||||
val d = dev ?: return false
|
||||
return looksLikeController(
|
||||
padSource = isPad(d),
|
||||
virtual = d.isVirtual,
|
||||
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
|
||||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
|
||||
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
|
||||
// a d-pad-only pad) still counts.
|
||||
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
|
||||
.any { it },
|
||||
)
|
||||
}
|
||||
|
||||
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
|
||||
* (an [InputDevice] cannot be built off a device). */
|
||||
fun looksLikeController(
|
||||
padSource: Boolean,
|
||||
virtual: Boolean,
|
||||
hasStick: Boolean,
|
||||
hasFaceButtons: Boolean,
|
||||
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
|
||||
|
||||
/**
|
||||
* All connected controllers, in system enumeration order — the devices that answer "is a pad
|
||||
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
|
||||
*/
|
||||
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
.filter { looksLikeController(it) }
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
@@ -293,6 +342,334 @@ object Gamepad {
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Controllers Android has no key layout for
|
||||
//
|
||||
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
|
||||
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
|
||||
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
|
||||
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
|
||||
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
|
||||
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
|
||||
// ORDER — so every keycode after the first divergence is somebody else's button.
|
||||
//
|
||||
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
|
||||
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
|
||||
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
|
||||
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
|
||||
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
|
||||
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
|
||||
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
|
||||
// symptom exactly.
|
||||
//
|
||||
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
|
||||
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
|
||||
// Two things keep it from breaking a pad that already works:
|
||||
//
|
||||
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
|
||||
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
|
||||
// positions too, and that is the fact — not the model — that separates the two firmwares
|
||||
// of the SAME Xbox pad, only the older of which needs any of this.
|
||||
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
|
||||
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
|
||||
// device-specific layout IS in force and knows this pad better than we do.
|
||||
//
|
||||
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
|
||||
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
|
||||
// which is why both pads work there on the same box.
|
||||
//
|
||||
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
|
||||
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
|
||||
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
|
||||
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
|
||||
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
|
||||
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
|
||||
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
|
||||
// does not. Declaration is not report order. Only the axes tell them apart.
|
||||
|
||||
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
|
||||
const val AXIS_NONE = -1
|
||||
|
||||
/**
|
||||
* The report order a controller's buttons are numbered in, and with it which scancode carries
|
||||
* which physical button. Resolved once per device by [padButtons] from what the device
|
||||
* declares; [correct] then maps one scancode to the keycode it should have produced.
|
||||
*/
|
||||
enum class PadButtons {
|
||||
/**
|
||||
* The keycode Android delivered is already right — a device-specific key layout is in
|
||||
* force, or the generic one happens to agree. [correct] changes nothing.
|
||||
*/
|
||||
NATIVE,
|
||||
|
||||
/**
|
||||
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
|
||||
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
|
||||
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
|
||||
* [buttonBit] deliberately drops — the wire carries the axis, never both.
|
||||
*/
|
||||
GENERIC_SONY,
|
||||
|
||||
/**
|
||||
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
|
||||
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
|
||||
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
|
||||
*/
|
||||
GENERIC_XBOX,
|
||||
|
||||
/**
|
||||
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
|
||||
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
|
||||
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
|
||||
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
|
||||
* face pair comes out swapped and nothing else is wrong.
|
||||
*/
|
||||
SONY_MODERN,
|
||||
;
|
||||
|
||||
/**
|
||||
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
|
||||
*
|
||||
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
|
||||
* said for [scan] — anything else is a device-specific layout's answer, which outranks
|
||||
* this table. That guard is what makes the correction idempotent and safe to run on
|
||||
* every pad: it can only ever fire where Android was guessing in the first place.
|
||||
*/
|
||||
fun correct(scan: Int, keyCode: Int): Int {
|
||||
if (this == NATIVE) return keyCode
|
||||
if (keyCode != genericKeyCode(scan)) return keyCode
|
||||
val fixed = when (this) {
|
||||
GENERIC_SONY -> when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
|
||||
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
GENERIC_XBOX -> when (scan) {
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> keyCode // 0x130 A / 0x131 B already agree
|
||||
}
|
||||
// Only the face pair; every other row of Generic.kl is right for these codes.
|
||||
SONY_MODERN -> when (scan) {
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
|
||||
else -> keyCode
|
||||
}
|
||||
NATIVE -> keyCode
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
|
||||
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
|
||||
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
|
||||
* events alone.
|
||||
*/
|
||||
fun genericKeyCode(scan: Int): Int = when (scan) {
|
||||
0x130 -> KeyEvent.KEYCODE_BUTTON_A
|
||||
0x131 -> KeyEvent.KEYCODE_BUTTON_B
|
||||
0x132 -> KeyEvent.KEYCODE_BUTTON_C
|
||||
0x133 -> KeyEvent.KEYCODE_BUTTON_X
|
||||
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
|
||||
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
|
||||
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
|
||||
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
|
||||
0x13b -> KeyEvent.KEYCODE_BUTTON_START
|
||||
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
|
||||
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* How one controller must be read: its button report order plus the axes its right stick and
|
||||
* analog triggers actually arrive on. Resolved once per device by [padMap].
|
||||
*/
|
||||
class PadMap(
|
||||
val buttons: PadButtons,
|
||||
val rightStickX: Int = MotionEvent.AXIS_Z,
|
||||
val rightStickY: Int = MotionEvent.AXIS_RZ,
|
||||
/**
|
||||
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
|
||||
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
|
||||
* report one pair, the other, or both have always needed.
|
||||
*/
|
||||
val leftTrigger: Int = AXIS_NONE,
|
||||
val rightTrigger: Int = AXIS_NONE,
|
||||
/** Those trigger axes rest at −1 rather than 0, measured off the device's own range. */
|
||||
val triggersSigned: Boolean = false,
|
||||
) {
|
||||
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
|
||||
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
|
||||
}
|
||||
|
||||
/** The map every pad with a key layout uses: Android's own names, unchanged. */
|
||||
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
|
||||
|
||||
/**
|
||||
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
|
||||
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
|
||||
* Nothing here depends on a live connection, so entries never need evicting.
|
||||
*/
|
||||
private val padMaps = ConcurrentHashMap<String, PadMap>()
|
||||
|
||||
/**
|
||||
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
|
||||
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
|
||||
* BUTTON_C and BUTTON_Z.
|
||||
*
|
||||
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
|
||||
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
|
||||
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
|
||||
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
|
||||
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
|
||||
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
|
||||
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
|
||||
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
|
||||
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
|
||||
*
|
||||
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
|
||||
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
|
||||
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
|
||||
* declaring six or more — a standard-layout pad that never presses either included. Read alone
|
||||
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
|
||||
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
|
||||
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
|
||||
* there is one — where a false positive costs nothing.
|
||||
*/
|
||||
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
|
||||
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
|
||||
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
|
||||
}
|
||||
|
||||
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
|
||||
* [InputDevice] cannot be built off a device). */
|
||||
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
|
||||
namedTriggers -> PadButtons.NATIVE
|
||||
declaresCZ && sony -> PadButtons.GENERIC_SONY
|
||||
declaresCZ -> PadButtons.GENERIC_XBOX
|
||||
sony -> PadButtons.SONY_MODERN
|
||||
else -> PadButtons.NATIVE
|
||||
}
|
||||
|
||||
/**
|
||||
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
|
||||
* arrive on, resolved once per device model and cached.
|
||||
*
|
||||
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
|
||||
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
|
||||
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
|
||||
* raw axes under the names the HID report gave them. Which two depends on the same report
|
||||
* order the buttons did:
|
||||
*
|
||||
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
|
||||
* stick, then the triggers — so the right stick is already right and only the triggers
|
||||
* (`AXIS_RX`/`AXIS_RY`) are missed;
|
||||
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
|
||||
* the shape that makes pulling a trigger swing the right stick.
|
||||
*
|
||||
* Whether those axes idle at −1 is MEASURED from the device's own range rather than assumed,
|
||||
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
|
||||
*/
|
||||
fun padMap(dev: InputDevice?): PadMap {
|
||||
if (dev == null) return NATIVE_MAP
|
||||
padMaps[dev.descriptor]?.let { return it }
|
||||
fun has(a: Int) = axis(dev, a) != null
|
||||
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
|
||||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
|
||||
val buttons = padButtons(dev, namedTriggers = named)
|
||||
val rx = axis(dev, MotionEvent.AXIS_RX)
|
||||
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
|
||||
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
|
||||
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
|
||||
(rx?.min ?: 0f) < -0.5f
|
||||
} else {
|
||||
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
|
||||
}
|
||||
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
|
||||
padMaps[dev.descriptor] = map
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half of [padMap], decided from four facts about the device so it can be pinned
|
||||
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
|
||||
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
|
||||
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
|
||||
* whichever axis pair the fallback picks, never assumed.
|
||||
*/
|
||||
fun padMap(
|
||||
buttons: PadButtons,
|
||||
namedTriggers: Boolean,
|
||||
hasRxRy: Boolean,
|
||||
restsNegative: Boolean,
|
||||
): PadMap = when {
|
||||
namedTriggers || !hasRxRy -> PadMap(buttons)
|
||||
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
|
||||
buttons == PadButtons.GENERIC_SONY -> PadMap(
|
||||
buttons,
|
||||
leftTrigger = MotionEvent.AXIS_RX,
|
||||
rightTrigger = MotionEvent.AXIS_RY,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
|
||||
// right stick makes pulling a trigger swing it.
|
||||
else -> PadMap(
|
||||
buttons,
|
||||
rightStickX = MotionEvent.AXIS_RX,
|
||||
rightStickY = MotionEvent.AXIS_RY,
|
||||
leftTrigger = MotionEvent.AXIS_Z,
|
||||
rightTrigger = MotionEvent.AXIS_RZ,
|
||||
triggersSigned = restsNegative,
|
||||
)
|
||||
}
|
||||
|
||||
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
|
||||
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
|
||||
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
|
||||
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
|
||||
|
||||
/**
|
||||
* The keycode [event] should have carried, given the controller it came from — [event]'s own
|
||||
* keycode for every pad Android has a key layout for, and the scancode's true button for one
|
||||
* it does not (see the block comment above [PadButtons]).
|
||||
*
|
||||
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
|
||||
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
|
||||
* pad is fixed in the menus and in the game at once. Events from anything that is not a
|
||||
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
|
||||
* untouched.
|
||||
*/
|
||||
fun padKeyCode(event: KeyEvent): Int {
|
||||
val dev = event.device ?: return event.keyCode
|
||||
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
|
||||
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
@@ -306,7 +683,12 @@ object Gamepad {
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
class AxisMapper(
|
||||
private val handle: Long,
|
||||
private val pad: Int,
|
||||
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
|
||||
private val map: PadMap = NATIVE_MAP,
|
||||
) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
@@ -317,30 +699,18 @@ object Gamepad {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
|
||||
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
|
||||
// reporting NONE of those names is one Android has no key layout for, and [map]
|
||||
// carries the raw axes its triggers really landed on instead.
|
||||
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
|
||||
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
|
||||
sendAxis(AXIS_LT, trigger(lt))
|
||||
sendAxis(AXIS_RT, trigger(rt))
|
||||
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
@@ -383,6 +753,17 @@ object Gamepad {
|
||||
hatY = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
|
||||
* else the max of the two names Android gives a trigger it does know.
|
||||
*/
|
||||
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
|
||||
if (resolvedAxis == AXIS_NONE) {
|
||||
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
|
||||
} else {
|
||||
map.level(event.getAxisValue(resolvedAxis))
|
||||
}
|
||||
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
|
||||
@@ -605,7 +605,7 @@ class GamepadRouter(
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
slots[dev.id] = slot
|
||||
|
||||
@@ -56,6 +56,28 @@ object NativeBridge {
|
||||
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
|
||||
framePartsOk: Boolean,
|
||||
audioChannels: Int,
|
||||
/** Requested audio sample rate: **`0` (with [audioBits] `0`) for the legacy Opus plane**, or
|
||||
* any rung of the lossless ladder — `44100`, `48000`, `88200`, `96000`, `176400`, both rate
|
||||
* families.
|
||||
*
|
||||
* ⚠⚠ **`48000`/`16` is NOT "the default" — it is the cheapest lossless rung.** Core sets
|
||||
* `CLIENT_CAP_AUDIO_HIRES` when either field is non-zero (it keys on "a format was
|
||||
* specified", so that 48/16 lossless is requestable at all), and the host's gate accepts
|
||||
* 48 kHz/16-bit as a supported format. Passing it as a stand-in for "unset" opts every
|
||||
* session into the `0xD3` plane on every host that has not deliberately opted out — which
|
||||
* since 2026-08-17 is all of them, the host gate having gone default-ON. Send `0`/`0`.
|
||||
*
|
||||
* A request on BOTH counts. The host runs its gate (its own `PUNKTFUNK_AUDIO_HIRES` switch
|
||||
* among them — now an opt-OUT, so it declines only at `=0` — plus whether a frame of this
|
||||
* format fits one datagram at all) and may answer
|
||||
* Opus; and the native side first proves THIS device can open the rate — AAudio grants an
|
||||
* explicit rate or fails the open, and there is no recovery once the wire is negotiated —
|
||||
* walking a fallback ladder and downgrading the request if it cannot. */
|
||||
audioRateHz: Int,
|
||||
/** Requested audio sample depth: `0` alongside a `0` [audioRateHz] for the legacy Opus
|
||||
* plane, else `16` or `24`. See [audioRateHz] for why `16` is a request rather than a
|
||||
* default; 24-bit is where lossless earns its bandwidth. */
|
||||
audioBits: Int,
|
||||
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
|
||||
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
|
||||
videoCodecs: Int,
|
||||
@@ -124,6 +146,14 @@ object NativeBridge {
|
||||
name: String,
|
||||
): String
|
||||
|
||||
/**
|
||||
* The native client's recent log ring rendered as one text bundle, oldest first,
|
||||
* prefixed by [header] (this app's identity line) — the body for "Send logs to host"
|
||||
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
|
||||
* Never empty; cheap (string copy, no I/O).
|
||||
*/
|
||||
external fun nativeRenderLogs(header: String): String
|
||||
|
||||
/**
|
||||
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
|
||||
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
|
||||
@@ -261,8 +291,25 @@ object NativeBridge {
|
||||
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
|
||||
* subdivides onto when the platform down-rates the app's choreographer stream. */
|
||||
panelFps: Int,
|
||||
/** The video SurfaceView's on-screen pixel size (0 = not laid out yet). The ASurfaceControl
|
||||
* present backend composites its layer in this coordinate space — the aspect-fitted display
|
||||
* footprint — rather than the window's rotated/scaled buffer geometry. */
|
||||
surfaceW: Int,
|
||||
surfaceH: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`.
|
||||
*
|
||||
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
|
||||
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
|
||||
* switches the window to draw into the display cutout a frame or two later, and neither
|
||||
* recreates the surface. Without this the layer keeps painting at its start-up size in the
|
||||
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
|
||||
* cheap (one atomic store), UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
@@ -275,12 +322,13 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 35 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 38 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs, audioCodec,
|
||||
* audioRateHz, audioBits]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -299,7 +347,12 @@ object NativeBridge {
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing;
|
||||
* 33/34 are the AUDIO plane — the playback ring's live depth in ms and the A/V sync loop's
|
||||
* smoothed offset in ms, positive meaning audio plays BEHIND the picture. Those two are live
|
||||
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference).
|
||||
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference;
|
||||
* 35–37 are the audio FORMAT the host RESOLVED at the handshake — `audioCodec` 0 = Opus on
|
||||
* `0xC9`, 2 = lossless PCM on `0xD3` — plus the resolved rate in Hz and depth in bits. Static
|
||||
* for the session, and separate from 33/34 because they answer a different question: not "how
|
||||
* late is the audio" but "is this the format the user asked for", which nothing else can tell
|
||||
* apart — a declined lossless session looks exactly like a granted one from the outside).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
@@ -612,4 +665,143 @@ object NativeBridge {
|
||||
* Returns the byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
|
||||
// ---- The Skia console UI (crates/pf-console-ui over EGL/GLES — clients/android/native/src/console) ----
|
||||
//
|
||||
// The same console shell the Linux/Windows session binary shows, drawn by native onto a
|
||||
// SurfaceView. Kotlin keeps the services and feeds the console's models as JSON in the model
|
||||
// types' own serde shape (HostRow, LibraryGame, ConsoleCmd, OverlayAction, Settings — see
|
||||
// `crates/pf-console-ui/src/model.rs` and `pf-client-core/src/trust.rs`); what the console
|
||||
// raises comes back through [nativeConsoleNextEvent]. Every call is main-thread-safe and cheap
|
||||
// except the two polls, which block ~100 ms and belong on their own threads.
|
||||
|
||||
/**
|
||||
* Whether this `.so` carries the console host at all. Present on EVERY ABI — armeabi-v7a has no
|
||||
* prebuilt Skia archive yet, so there the rest of these symbols DO NOT EXIST and calling one is
|
||||
* an UnsatisfiedLinkError. Ask this first.
|
||||
*/
|
||||
external fun nativeConsoleAvailable(): Boolean
|
||||
|
||||
/**
|
||||
* Build the console: [optionsJson] = `{device_name, gpu_cache_bytes, settings: <trust::Settings>,
|
||||
* profiles: [[id, name]], known_hosts: <KnownHosts>, entry: {} | {"library": <HostRow>}}`.
|
||||
* Returns a handle (its render thread parked until a surface arrives), or `0` on a bad options
|
||||
* document. EGL/Skia failures arrive later as a `{"dead": …}` event.
|
||||
*/
|
||||
external fun nativeConsoleCreate(optionsJson: String): Long
|
||||
|
||||
/** Stop the render thread (joined) and free. Stop + join the event poll thread FIRST. */
|
||||
external fun nativeConsoleDestroy(handle: Long)
|
||||
|
||||
/** The SurfaceView's surface is up. */
|
||||
external fun nativeConsoleSurfaceCreated(handle: Long, surface: android.view.Surface)
|
||||
|
||||
/** The surface's size changed. */
|
||||
external fun nativeConsoleSurfaceChanged(handle: Long)
|
||||
|
||||
/** BLOCKS until the render thread has let go of the surface — call from `surfaceDestroyed`. */
|
||||
external fun nativeConsoleSurfaceDestroyed(handle: Long)
|
||||
|
||||
/** Safe-area insets in surface pixels + the design-unit scale (`0` = the shell's own formula). */
|
||||
external fun nativeConsoleSetViewport(
|
||||
handle: Long,
|
||||
left: Float,
|
||||
top: Float,
|
||||
right: Float,
|
||||
bottom: Float,
|
||||
scale: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* The raw pad, whenever it changes: [buttons] bit i = a, b, x, y, l1, r1 held; [lx]/[ly] the left
|
||||
* stick in wire units (±32767, +y down); [dpad] bit i = up, down, left, right held. Native runs
|
||||
* the shared menu synthesizer over it (dead zone, repeat, hysteresis).
|
||||
*/
|
||||
external fun nativeConsolePadSample(handle: Long, buttons: Int, lx: Int, ly: Int, dpad: Int)
|
||||
|
||||
/**
|
||||
* A discrete menu event: 0..3 move up/down/left/right, 4 confirm, 5 back, 6 secondary (Y),
|
||||
* 7 tertiary (X), 8 jump back (L1), 9 jump forward (R1). For input that is already an event on
|
||||
* this side (a TV remote's D-pad keys, the touch legend).
|
||||
*/
|
||||
external fun nativeConsoleMenu(handle: Long, event: Int)
|
||||
|
||||
/**
|
||||
* Pointer input in surface pixels: [kind] 0 move, 1 primary down, 2 primary up, 3 secondary
|
||||
* down (= Back), 4 wheel ([dy] steps, + = up), 5 cancel.
|
||||
*/
|
||||
external fun nativeConsolePointer(handle: Long, kind: Int, x: Float, y: Float, dy: Float)
|
||||
|
||||
/**
|
||||
* A hardware key the console understands: 0..3 left/right/up/down, 4 return, 5 space,
|
||||
* 6 escape, 7 backspace, 8 page up, 9 page down, 10 tab, 11 Y, 12 X.
|
||||
*/
|
||||
external fun nativeConsoleKey(handle: Long, key: Int, shift: Boolean, repeat: Boolean)
|
||||
|
||||
/** Typed characters while the console reports `{"editing": true}`. */
|
||||
external fun nativeConsoleText(handle: Long, text: String)
|
||||
|
||||
/**
|
||||
* Where the session the console asked for stands: 0 connecting, 1 streaming, 2 failed([message]),
|
||||
* 3 ended ([message] = the abnormal reason, or "" for a clean end), 4 reconnecting([message]).
|
||||
*/
|
||||
external fun nativeConsoleSessionPhase(handle: Long, phase: Int, message: String)
|
||||
|
||||
/** Re-root the console: `{}` = Home, `{"library": <HostRow>}` = that host's shelf over Home. */
|
||||
external fun nativeConsoleNavigate(handle: Long, entryJson: String)
|
||||
|
||||
/**
|
||||
* The connected controllers: `{"label": "DualSense", "pref": 2, "pads": [{name, key, pref,
|
||||
* steam_virtual, battery: {percent, charging} | null}]}` — the chip and the settings rows.
|
||||
*/
|
||||
external fun nativeConsoleSetPads(handle: Long, padsJson: String)
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next event: `{"action": <OverlayAction>}`, `{"pulse": "move" |
|
||||
* "confirm" | "boundary"}`, `{"editing": bool}`, `{"settings": <Settings>}` (persist it),
|
||||
* `{"gles": 2 | 3}`, `{"dead": "<why>"}`. `""` on timeout. Call from a dedicated poll thread.
|
||||
*/
|
||||
external fun nativeConsoleNextEvent(handle: Long): String
|
||||
|
||||
/** Every `ConsoleCmd` queued since the last call, as a JSON array (`[]` when none). */
|
||||
external fun nativeConsoleDrainCmds(handle: Long): String
|
||||
|
||||
/** The home carousel's rows: `[HostRow]`. */
|
||||
external fun nativeConsoleSetHosts(handle: Long, json: String)
|
||||
|
||||
/** The pairing ceremony's phase: `"Idle"`, `"Busy"`, `{"Failed": "why"}`, `{"Paired": {"key": …}}`. */
|
||||
external fun nativeConsoleSetPair(handle: Long, json: String)
|
||||
|
||||
/** The wake card's status (`WakeStatus` JSON) or `null` to clear it. */
|
||||
external fun nativeConsoleSetWake(handle: Long, json: String)
|
||||
|
||||
/** A one-shot toast from a service worker. */
|
||||
external fun nativeConsoleNotice(handle: Long, text: String)
|
||||
|
||||
/** A library fetch is starting for the shelf on screen (bumps the epoch, sets Loading). */
|
||||
external fun nativeConsoleLibraryBegin(handle: Long)
|
||||
|
||||
/** `"Loading"`, `"Empty"`, `"Ready"`, or `{"Error": {"title", "body", "can_retry"}}`. */
|
||||
external fun nativeConsoleLibraryPhase(handle: Long, json: String)
|
||||
|
||||
/** The catalog `[LibraryGame]`; [cached] = the last-known list shown while the fetch runs. */
|
||||
external fun nativeConsoleLibraryGames(handle: Long, json: String, cached: Boolean)
|
||||
|
||||
/** One title's poster, encoded (JPEG/PNG bytes). */
|
||||
external fun nativeConsoleLibraryArt(handle: Long, id: String, bytes: ByteArray)
|
||||
|
||||
/** The ids the host has up: `["steam:570", …]`. */
|
||||
external fun nativeConsoleLibraryRunning(handle: Long, json: String)
|
||||
|
||||
/** 0 fresh, 1 waking, 2 offline — the cached shelf's staleness note. */
|
||||
external fun nativeConsoleLibraryStale(handle: Long, stale: Int)
|
||||
|
||||
/** A settings change made elsewhere (touch UI, deep link): the shell reads it next. Not a save. */
|
||||
external fun nativeConsoleSetSettings(handle: Long, json: String)
|
||||
|
||||
/** The profile catalog `[[id, name]]`. */
|
||||
external fun nativeConsoleSetProfiles(handle: Long, json: String)
|
||||
|
||||
/** The known-hosts records (`KnownHosts` JSON) the console builds `punktfunk://` links from. */
|
||||
external fun nativeConsoleSetKnownHosts(handle: Long, json: String)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ data class GameEntry(
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
/**
|
||||
* The host's platform tag (`platform` in the catalog — a ROM manager's console name; Steam
|
||||
* sets none). What the console's Collections group by; carried through verbatim.
|
||||
*/
|
||||
val platform: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
@@ -105,6 +110,49 @@ 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()
|
||||
|
||||
/**
|
||||
* Is this the "can't reach it" failure — the only one worth waiting out?
|
||||
*
|
||||
* A rejected certificate does not become acceptable by retrying, and asking an unpaired host
|
||||
* twelve times only delays telling the user what is actually wrong. Lives here rather than at
|
||||
* the call site so the retry loop and the error copy can never disagree about which failures
|
||||
* are transient.
|
||||
*/
|
||||
val isTransient: Boolean get() = this is Error
|
||||
}
|
||||
|
||||
/**
|
||||
* One game the host currently has launched, from `GET /api/v1/status`.
|
||||
*
|
||||
* A deliberately partial mirror of the host's `ActiveGame`: only the fields a client can act on.
|
||||
* The web console's view of this payload carries more (which session, which plane, the grace
|
||||
* countdown), and none of that is a player's business from the library shelf.
|
||||
*/
|
||||
data class RunningGame(
|
||||
/**
|
||||
* Store-qualified library id (`steam:570`) — the key that lines this up with a [GameEntry].
|
||||
* Null for an operator-typed GameStream command, which has no catalog entry behind it.
|
||||
*/
|
||||
val appId: String?,
|
||||
val title: String,
|
||||
/**
|
||||
* `launching` | `running` | `exited` | `untracked` | `grace`. A plain String on purpose: the
|
||||
* host owns the vocabulary and adds to it (`untracked` arrived in 0.30), so an unknown value
|
||||
* must never fail the decode of the whole list.
|
||||
*/
|
||||
val state: String,
|
||||
) {
|
||||
/**
|
||||
* Is this title *up on the host right now* — i.e. would picking it take the player back into
|
||||
* it rather than start it?
|
||||
*
|
||||
* `untracked` counts: the host cannot follow that process, but it did launch it and has no
|
||||
* evidence it stopped. `grace` counts too — its session is gone but the game is still running,
|
||||
* which is precisely the case where getting back in promptly matters most. Only a confirmed
|
||||
* `exited` does not.
|
||||
*/
|
||||
val isUp: Boolean get() = state != "exited"
|
||||
}
|
||||
|
||||
object LibraryClient {
|
||||
@@ -150,6 +198,55 @@ object LibraryClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the host currently has running, from `GET /api/v1/status`.
|
||||
*
|
||||
* Same lane, same identity, no new host work: `/status` is already on the paired-certificate
|
||||
* allowlist (the host's `mgmt::auth::cert_may_access`) alongside `/library`, and has carried a
|
||||
* `games[]` array since the session⇄game lifetime work. This client simply never read it — so
|
||||
* a player had no way to see, from the device they browse on, that something was already up.
|
||||
*
|
||||
* **Best-effort by contract**: an older host, an unreachable one, or a shape we don't recognize
|
||||
* yields an empty list rather than an error. Nothing here is worth failing a library screen
|
||||
* over — the worst case is a Resume badge that doesn't appear. BLOCKING; call from IO.
|
||||
*/
|
||||
fun fetchRunning(
|
||||
address: String,
|
||||
mgmtPort: Int = DEFAULT_MGMT_PORT,
|
||||
certPem: String,
|
||||
keyPem: String,
|
||||
fpHex: String,
|
||||
): List<RunningGame> {
|
||||
if (fpHex.isBlank()) return emptyList()
|
||||
return try {
|
||||
val client = mtlsHttpClient(certPem, keyPem, address, fpHex)
|
||||
val req = Request.Builder().url("https://$address:$mgmtPort/api/v1/status").build()
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.code != 200) return emptyList()
|
||||
parseRunning(resp.body?.string().orEmpty())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Just the `games[]` slice of `/status`; everything else on that payload is the console's. */
|
||||
private fun parseRunning(json: String): List<RunningGame> {
|
||||
val arr = JSONObject(json).optJSONArray("games") ?: return emptyList()
|
||||
val out = ArrayList<RunningGame>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
out.add(
|
||||
RunningGame(
|
||||
appId = str(o, "app_id"),
|
||||
title = o.optString("title"),
|
||||
state = o.optString("state"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parse(json: String, base: String): List<GameEntry> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<GameEntry>(arr.length())
|
||||
@@ -168,6 +265,7 @@ object LibraryClient {
|
||||
),
|
||||
role = str(o, "role"),
|
||||
icon = str(o, "icon"),
|
||||
platform = str(o, "platform"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package io.unom.punktfunk.kit.library
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
// On-disk cache for a host's library CATALOG — the list of titles, not their art. The Android
|
||||
// mirror of the Apple client's `LibraryCache.swift` and the Rust `pf_client_core::library_cache`.
|
||||
//
|
||||
// Cover art has been cached by Coil for a while; the catalog behind it never was. Every visit to a
|
||||
// library refetched `GET /api/v1/library` and showed a spinner until that call returned. A host
|
||||
// that is asleep, or simply not reachable yet, therefore had an EMPTY library — which is the
|
||||
// opposite of what a player wants from the screen they use to decide what to play, and it makes
|
||||
// waking a host on library entry pointless: there would be nothing to look at while it boots.
|
||||
//
|
||||
// So the catalog is cached per host and rendered immediately, marked stale, and reconciled when the
|
||||
// host answers.
|
||||
//
|
||||
// Cache directory (`context.cacheDir`), not files: every byte is re-derivable from the host, so
|
||||
// Android is welcome to evict it under storage pressure. Unlike art, a catalog is small (a few
|
||||
// hundred KB for a big library), so there is no size budget here — one file per host, replaced
|
||||
// wholesale.
|
||||
//
|
||||
// Takes a plain [File] directory rather than a Context so it can be unit-tested against a temp
|
||||
// dir, exactly like the Apple original.
|
||||
|
||||
/** A host's library as last seen, with when that was (epoch millis). */
|
||||
data class CachedLibrary(val games: List<GameEntry>, val fetchedAt: Long)
|
||||
|
||||
class LibraryCache(private val directory: File) {
|
||||
|
||||
/**
|
||||
* This host's last-known catalog, or null if there is no usable one.
|
||||
*
|
||||
* A catalog written by an older build whose [GameEntry] had different fields decodes to null
|
||||
* rather than throwing: a miss costs one fetch, which is what would have happened anyway.
|
||||
* Never surfaced as an error.
|
||||
*/
|
||||
fun load(hostKey: String): CachedLibrary? = try {
|
||||
val file = pathFor(hostKey)
|
||||
if (!file.isFile) {
|
||||
null
|
||||
} else {
|
||||
val root = JSONObject(file.readText())
|
||||
val arr = root.getJSONArray("games")
|
||||
val games = ArrayList<GameEntry>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
games.add(decode(arr.getJSONObject(i)))
|
||||
}
|
||||
CachedLibrary(games, root.optLong("fetchedAt"))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember this host's catalog. Best-effort: a cache that can't write is a slower app, not a
|
||||
* broken one, so every failure here is swallowed.
|
||||
*/
|
||||
fun store(hostKey: String, games: List<GameEntry>) {
|
||||
// An empty catalog is not worth remembering: it is indistinguishable from "never fetched"
|
||||
// when read back, and caching it would pin a blank library over a host that has titles.
|
||||
if (games.isEmpty()) return
|
||||
try {
|
||||
val root = JSONObject()
|
||||
.put("fetchedAt", System.currentTimeMillis())
|
||||
.put("games", JSONArray().apply { games.forEach { put(encode(it)) } })
|
||||
directory.mkdirs()
|
||||
// Write-then-rename: an app killed mid-write must not leave a half-file that the next
|
||||
// launch reads as a corrupt catalog.
|
||||
val target = pathFor(hostKey)
|
||||
val tmp = File(target.parentFile, "${target.name}.tmp")
|
||||
tmp.writeText(root.toString())
|
||||
if (!tmp.renameTo(target)) {
|
||||
tmp.delete()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// a cache that can't write is a slower app, not a broken one
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a host's catalog — part of forgetting the host, so a removed host leaves no list of
|
||||
* what somebody plays behind on the device.
|
||||
*/
|
||||
fun forget(hostKey: String) {
|
||||
runCatching { pathFor(hostKey).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashed rather than used verbatim: a host key is user-controlled text (a name, an address)
|
||||
* and must never be able to reach out of this directory (`../`) or exceed a filename length
|
||||
* limit.
|
||||
*/
|
||||
private fun pathFor(hostKey: String): File {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(hostKey.toByteArray())
|
||||
return File(directory, digest.joinToString("") { "%02x".format(it) } + ".json")
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire shape, so a cached file and a host response decode through the same [GameEntry].
|
||||
* Art URLs are stored ALREADY RESOLVED to absolute — the host-relative form only means
|
||||
* anything next to the base it was fetched from, and re-deriving that base on load would make
|
||||
* a cache entry depend on the address the host happens to have today.
|
||||
*/
|
||||
private fun encode(g: GameEntry): JSONObject = JSONObject()
|
||||
.put("id", g.id)
|
||||
.put("store", g.store)
|
||||
.put("title", g.title)
|
||||
.put("art", JSONObject().apply {
|
||||
g.art.portrait?.let { put("portrait", it) }
|
||||
g.art.header?.let { put("header", it) }
|
||||
g.art.hero?.let { put("hero", it) }
|
||||
})
|
||||
.apply {
|
||||
g.role?.let { put("role", it) }
|
||||
g.icon?.let { put("icon", it) }
|
||||
g.platform?.let { put("platform", it) }
|
||||
}
|
||||
|
||||
private fun decode(o: JSONObject): GameEntry {
|
||||
val art = o.optJSONObject("art") ?: JSONObject()
|
||||
return GameEntry(
|
||||
id = o.optString("id"),
|
||||
store = o.optString("store"),
|
||||
title = o.optString("title"),
|
||||
art = Artwork(
|
||||
portrait = nullable(art, "portrait"),
|
||||
header = nullable(art, "header"),
|
||||
hero = nullable(art, "hero"),
|
||||
),
|
||||
role = nullable(o, "role"),
|
||||
icon = nullable(o, "icon"),
|
||||
platform = nullable(o, "platform"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nullable(o: JSONObject, key: String): String? =
|
||||
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
|
||||
|
||||
companion object {
|
||||
/** The app's standard location for this cache, under Android's evictable cache dir. */
|
||||
fun standard(cacheDir: File): LibraryCache = LibraryCache(File(cacheDir, "punktfunk-library"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.PadButtons.correct] — the scancode resolution for controllers Android
|
||||
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
|
||||
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
|
||||
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
|
||||
* landing on the wrong actions — "L1 being L2". Neither pad has a key layout on that box (AOSP
|
||||
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
|
||||
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
|
||||
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
|
||||
* after the first divergence belongs to a different button.
|
||||
*
|
||||
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
|
||||
* right; the assertions read it back the other way.
|
||||
*/
|
||||
class PadButtonsTest {
|
||||
|
||||
private fun sony(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
private fun xbox(scan: Int) =
|
||||
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
|
||||
/**
|
||||
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
|
||||
* BUTTON_L1 — so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
|
||||
* read as a face button.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's shoulders stop being each other's buttons`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
|
||||
}
|
||||
|
||||
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
|
||||
@Test
|
||||
fun `a DualSense's face buttons land on their Xbox positions`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
|
||||
* which took the exit, mic and stats chords with it — every one of them is built on Select.
|
||||
*/
|
||||
@Test
|
||||
fun `a DualSense's menu buttons and stick clicks are themselves`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
|
||||
}
|
||||
|
||||
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
|
||||
@Test
|
||||
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
|
||||
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
|
||||
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
|
||||
}
|
||||
|
||||
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
|
||||
@Test
|
||||
fun `an Xbox pad numbering straight through keeps its own layout`() {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
|
||||
}
|
||||
|
||||
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
|
||||
@Test
|
||||
fun `a driver-backed Sony pad has only its face pair corrected`() {
|
||||
val m = Gamepad.PadButtons.SONY_MODERN
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
|
||||
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
|
||||
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
|
||||
* `Generic.kl` would have said came from a device-specific key layout, which knows this
|
||||
* controller better than any table here. Correcting it would break a pad that works.
|
||||
*/
|
||||
@Test
|
||||
fun `a keycode a device layout already resolved is never second-guessed`() {
|
||||
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
}
|
||||
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
|
||||
assertEquals(
|
||||
KeyEvent.KEYCODE_BACK,
|
||||
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
|
||||
@Test
|
||||
fun `correction is idempotent`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
|
||||
assertEquals(once, p.correct(scan, once))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis half. A pad that names its triggers something Android knows is read exactly as it
|
||||
* always was — this is the branch that must NOT fire on the pads that already work.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is read unchanged`() {
|
||||
for (p in Gamepad.PadButtons.entries) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
|
||||
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
|
||||
}
|
||||
// Same when there is no Rx/Ry to fall back to in the first place.
|
||||
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
|
||||
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry — left stick, right
|
||||
* stick, then the triggers. Only the triggers were being missed; the sticks already read
|
||||
* right and must be left alone.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
|
||||
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
|
||||
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it — so the two
|
||||
* pairs must never be mixed up, which is the whole point of pinning them.
|
||||
*/
|
||||
@Test
|
||||
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
|
||||
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
|
||||
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
|
||||
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
|
||||
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
|
||||
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger axis that idles at −1 is rescaled; one that idles at 0 must NOT be, or it would
|
||||
* read as a permanent half-pull. Which it is gets measured off the device, never assumed —
|
||||
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
|
||||
*/
|
||||
@Test
|
||||
fun `only a trigger that idles negative is rescaled`() {
|
||||
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
|
||||
assertEquals(0f, signed.level(-1f), 1e-6f)
|
||||
assertEquals(0.5f, signed.level(0f), 1e-6f)
|
||||
assertEquals(1f, signed.level(1f), 1e-6f)
|
||||
|
||||
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
|
||||
assertEquals(0f, unsigned.level(0f), 1e-6f)
|
||||
assertEquals(1f, unsigned.level(1f), 1e-6f)
|
||||
}
|
||||
|
||||
/** A pad Android does know is untouched, which is most of them. */
|
||||
@Test
|
||||
fun `a pad with a key layout is left alone`() {
|
||||
for (scan in 0x130..0x13e) {
|
||||
val generic = Gamepad.genericKeyCode(scan)
|
||||
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
|
||||
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
|
||||
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
|
||||
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
|
||||
* pad ever presses them or not. Naming the triggers is what tells the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `a pad that names its triggers is never corrected, whatever it declares`() {
|
||||
for (sony in listOf(false, true)) {
|
||||
for (declaresCZ in listOf(false, true)) {
|
||||
assertEquals(
|
||||
Gamepad.PadButtons.NATIVE,
|
||||
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four buttons the field reports named, on a pad whose report order is already standard:
|
||||
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
|
||||
* keeps them themselves — the correction tables are right for the pads they are for, and this
|
||||
* is about not reaching one of them.
|
||||
*/
|
||||
@Test
|
||||
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
|
||||
val native = Gamepad.PadButtons.NATIVE
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
// What the old heuristic did to each of them, kept here so the difference stays visible.
|
||||
val wrong = Gamepad.PadButtons.GENERIC_XBOX
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
|
||||
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
|
||||
}
|
||||
|
||||
/** Past the gate, which straight-through order to read is still the question it always was. */
|
||||
@Test
|
||||
fun `an unnamed-trigger pad still resolves its report order`() {
|
||||
fun order(sony: Boolean, declaresCZ: Boolean) =
|
||||
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
|
||||
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
|
||||
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The truth table behind "is a controller attached" — the question the console UI's
|
||||
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
|
||||
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
|
||||
* dismiss it, because the phantom pad never disconnects.
|
||||
*/
|
||||
class PadPresenceTest {
|
||||
|
||||
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
|
||||
@Test
|
||||
fun realPadsCount() {
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// An arcade stick / d-pad-only pad — buttons, no analog stick.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
// A wheel or flight stick — axes, no A/B.
|
||||
assertTrue(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
|
||||
* gamepad source class. This is the field report — the console UI that could not be dismissed. */
|
||||
@Test
|
||||
fun virtualDevicesAreNotControllers() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A device that claims a pad source with nothing behind it is not a pad either. */
|
||||
@Test
|
||||
fun aSourceClaimWithoutHardwareIsNotAController() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
|
||||
@Test
|
||||
fun nonPadSourcesNeverCount() {
|
||||
assertFalse(
|
||||
Gamepad.looksLikeController(
|
||||
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,5 +92,29 @@ opus = "0.3"
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
# The Skia console UI (design/android-skia-console-port.md): the SAME `pf-console-ui` shell the
|
||||
# Linux/Windows session binary shows, drawn here through Skia's GL backend on an EGL surface
|
||||
# (`src/console/`). `default-features = false` turns its Vulkan overlay off — this host owns the
|
||||
# surface. `pf-client-core` for the portable types the shell speaks (settings, menu events,
|
||||
# overlay actions); `default-features = false` like every other consumer (its default is the
|
||||
# PyroWave decoder, C++ nobody here wants).
|
||||
#
|
||||
# All three shipping ABIs. rust-skia publishes prebuilt Skia archives for aarch64/x86_64/
|
||||
# i686-linux-android but NOT armv7-linux-androideabi (the 32-bit TV boxes) — that one comes from
|
||||
# OUR mirror through `SKIA_BINARIES_URL` (kit/build.gradle.kts forwards it; the CI variable of
|
||||
# the same name; sha256 table beside it), built once with skia-bindings itself (design
|
||||
# android-skia-console-port.md WP6). 🛑 skia-bindings does not fail when no archive matches — it
|
||||
# silently builds Skia from source — so an armeabi-v7a build WITHOUT the mirror url is a
|
||||
# multi-minute source build, and every ABI's log must show `DOWNLOAD AND INSTALL SUCCEEDED`.
|
||||
# (Continues the `cfg(target_os = "android")` table above — TOML allows a table header once.)
|
||||
pf-console-ui = { path = "../../../crates/pf-console-ui", default-features = false }
|
||||
pf-client-core = { path = "../../../crates/pf-client-core", default-features = false }
|
||||
# `gl` + `textlayout` = the prebuilt key `gl-jpegd-jpege-pdf-textlayout` (verify
|
||||
# `DOWNLOAD AND INSTALL SUCCEEDED` in the build log on every bump — see pf-console-ui/Cargo.toml).
|
||||
skia-safe = { version = "0.99", features = ["gl", "textlayout"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+582
-189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
||||
//! The audio format a session RESOLVED, and the millisecond ⇄ interleaved-sample arithmetic every
|
||||
//! figure the playback plane reports is expressed in.
|
||||
//!
|
||||
//! **Why this is its own module, and why it is NOT `#[cfg(target_os = "android")]` like the
|
||||
//! [`crate::audio`] that owns it.** The conversions below are the part of the plane that was
|
||||
//! *wrong* — see [`ms_to_samples`] — and the whole class of defect is one that measures cleanly
|
||||
//! while being off by a fixed percentage. A bug like that is only ever caught by arithmetic tests,
|
||||
//! and an arithmetic test that can only run on a phone is a test that runs when someone remembers.
|
||||
//! Nothing here touches AAudio, so nothing here needs a device: it compiles and is tested on the
|
||||
//! ordinary `cargo test -p punktfunk-client-android --lib` leg, and `:kit:cargoNdkClippy` lints it
|
||||
//! at both Android widths on top.
|
||||
|
||||
use punktfunk_core::audio::pcm;
|
||||
// Only [`SessionAudio::of`] touches the connector, and only on device — see the `cfg` there.
|
||||
#[cfg(target_os = "android")]
|
||||
use punktfunk_core::client::NativeClient;
|
||||
|
||||
/// The `0xC9` plane's frame duration: fixed by the protocol at 5 ms (the host's `audio_thread`),
|
||||
/// not negotiated. Only `0xD3` carries `audio_frame_us`.
|
||||
pub(crate) const OPUS_FRAME_US: u32 = 5_000;
|
||||
|
||||
// ---- ms ⇄ interleaved samples: multiply FIRST, divide LAST ------------------------------------
|
||||
//
|
||||
// This mirrors `punktfunk_core::audio`'s own pair, and it mirrors it because the defect it fixes
|
||||
// was copied from there. Both used to precompute `per_ms = rate_hz / 1000 * channels` and express
|
||||
// every ms-denominated figure as `ms * per_ms`. **That division happens first**, so 44 100 Hz
|
||||
// became 44 samples per millisecond and every depth, hard cap and reported `buffer_ms` was 2.3 %
|
||||
// out — quietly, permanently, and only on the rates the old ladder happened not to offer. 48 000
|
||||
// and 96 000 were exact by luck: they divide.
|
||||
//
|
||||
// Keeping the rate and the channel count as the two numbers they are, and dividing last, is exact
|
||||
// at every rate on `pcm::rate_is_supported`'s ladder for one integer division per conversion, and
|
||||
// 48/96 kHz stay bit-identical by construction (`per_sec == 1000 × per_ms` exactly there, so both
|
||||
// conversions reduce to the expression they replace).
|
||||
|
||||
/// Interleaved samples per second at a negotiated layout — the denominator both conversions share.
|
||||
///
|
||||
/// `max(1)` on both factors: a degenerate layout must not divide by zero on a realtime thread.
|
||||
/// [`SessionAudio::of`] already clamps, so this is the belt to that pair of braces.
|
||||
fn interleaved_per_sec(rate_hz: u32, channels: usize) -> u64 {
|
||||
let hz = if rate_hz == 0 { 1 } else { rate_hz } as u64;
|
||||
let ch = if channels == 0 { 1 } else { channels } as u64;
|
||||
hz * ch
|
||||
}
|
||||
|
||||
/// `ms` milliseconds of audio, in interleaved samples.
|
||||
///
|
||||
/// u64 intermediates because the product is large where a `usize` may be 32 bits — and on this
|
||||
/// client that is not hypothetical: **armeabi-v7a is a shipping ABI** (every 32-bit Google TV /
|
||||
/// Android TV box), so the same expression runs at both widths. `JitterTuning::AAUDIO`'s hard cap
|
||||
/// against 176 400 Hz × 8 ch would be fine, but the type is what makes that a fact rather than an
|
||||
/// audit. Saturating rather than wrapping, because a wrapped window is a *tiny* one — a buffer cap
|
||||
/// that is instantly exceeded instead of one that is never reached.
|
||||
fn ms_to_samples(rate_hz: u32, channels: usize, ms: u32) -> usize {
|
||||
let n = ms as u64 * interleaved_per_sec(rate_hz, channels) / 1000;
|
||||
if n > u32::MAX as u64 {
|
||||
u32::MAX as usize
|
||||
} else {
|
||||
n as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved samples back to whole milliseconds — the exact inverse of [`ms_to_samples`].
|
||||
///
|
||||
/// u128 because `samples` arrives from a ring depth and nothing bounds it: `usize::MAX * 1000`
|
||||
/// overflows a u64 on the 64-bit ABI.
|
||||
fn samples_to_ms(rate_hz: u32, channels: usize, samples: usize) -> u32 {
|
||||
let ms = samples as u128 * 1000 / interleaved_per_sec(rate_hz, channels) as u128;
|
||||
if ms > u32::MAX as u128 {
|
||||
u32::MAX
|
||||
} else {
|
||||
ms as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio format this session RESOLVED, read once from the connector and threaded through the
|
||||
/// whole plane.
|
||||
///
|
||||
/// Gathered into one value rather than passed as five parameters because the fields are only
|
||||
/// meaningful together: a rate without the codec cannot tell a 48 kHz lossless session from a
|
||||
/// 48 kHz Opus one, and those two agree on every other resolved value.
|
||||
///
|
||||
/// ⚠ Everything here is what the HOST resolved, never what this client asked for. A client that
|
||||
/// requests 96 kHz, is answered 48 kHz and opens at 96 kHz anyway is `design/hi-res-audio.md`
|
||||
/// §4.3's failure one end further along — a session that audits clean at both ends and plays the
|
||||
/// wrong content.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SessionAudio {
|
||||
/// [`punktfunk_core::quic::AUDIO_CODEC_OPUS`] (`0xC9`) or
|
||||
/// [`punktfunk_core::quic::AUDIO_CODEC_PCM`] (`0xD3`) — what SELECTS the decoder, and the only
|
||||
/// field that can.
|
||||
pub(crate) codec: u8,
|
||||
/// The resolved sample rate: 48 000 on every Opus session, and any rung of
|
||||
/// [`pcm::rate_is_supported`] on `0xD3` — 44 100 / 48 000 / 88 200 / 96 000 / 176 400. Both
|
||||
/// families are exact in every conversion this module performs; the 44.1 one was deferred
|
||||
/// only for as long as the arithmetic above divided before it multiplied.
|
||||
pub(crate) rate_hz: u32,
|
||||
/// The resolved sample depth (16 or 24) — the stride `0xD3` payloads are unpacked at.
|
||||
/// Meaningless on the Opus plane, which decodes to f32 regardless.
|
||||
pub(crate) bits: u8,
|
||||
/// The resolved, normalized channel count (2 / 6 / 8).
|
||||
///
|
||||
/// ⚠ Not "2" any more on the lossless plane. Surround was excluded from `0xD3` because a 5.1
|
||||
/// frame did not fit a datagram at the default MTU, but the ladder is channel-aware and the
|
||||
/// restriction was one host-side condition, not a wire limitation: at the conservative
|
||||
/// datagram size a 48 kHz/16-bit 5.1 session negotiates 2 ms frames and a 24-bit one 1 ms
|
||||
/// (a thousand datagrams a second), while 96/24 5.1 still fits nothing and is declined. Every
|
||||
/// per-frame size below is taken from THIS count for that reason.
|
||||
pub(crate) channels: usize,
|
||||
/// How much audio one datagram carries. Negotiated from the path MTU on `0xD3` (at 96 kHz /
|
||||
/// 24-bit stereo the default MTU ceiling only leaves room for 2 ms, and for 24-bit surround
|
||||
/// 1 ms), and the Opus plane's fixed 5 ms otherwise — folded to one field here so nothing
|
||||
/// downstream has to branch to size a buffer.
|
||||
///
|
||||
/// ⚠ A **label**, not a duration. It is a whole number of samples per channel only when the
|
||||
/// rate divides the rung, which the 44.1 kHz family never does: a nominal 5 ms frame at
|
||||
/// 44 100 Hz carries 220 samples per channel = 4 988 662 ns. Size from [`Self::frame_samples`];
|
||||
/// time from [`pcm::frame_duration_ns`].
|
||||
pub(crate) frame_us: u32,
|
||||
}
|
||||
|
||||
impl SessionAudio {
|
||||
/// Read the whole resolved format off the connector, once, at the top of the plane.
|
||||
///
|
||||
/// Android-only, because a [`NativeClient`] only exists once a session has been negotiated on
|
||||
/// a device — the pure half is [`resolved`](Self::resolved), which is where the clamping lives
|
||||
/// and what the tests exercise. Left ungated it would be dead code on the host build, and
|
||||
/// `-D warnings` is a hard gate there.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn of(client: &NativeClient) -> SessionAudio {
|
||||
SessionAudio::resolved(
|
||||
client.audio_codec,
|
||||
client.audio_sample_rate_hz,
|
||||
client.audio_bits,
|
||||
client.audio_channels,
|
||||
u32::from(client.audio_frame_us),
|
||||
)
|
||||
}
|
||||
|
||||
/// The `Welcome`'s five audio fields, clamped into something every buffer below can be sized
|
||||
/// from. **Every value here arrives off the wire**, so each clamp is defending a realtime
|
||||
/// thread against a host that is old, wrong, or hostile — none of them bite a conforming one.
|
||||
pub(crate) fn resolved(
|
||||
codec: u8,
|
||||
rate_hz: u32,
|
||||
bits: u8,
|
||||
channels: u8,
|
||||
frame_us: u32,
|
||||
) -> SessionAudio {
|
||||
let is_pcm = codec == punktfunk_core::quic::AUDIO_CODEC_PCM;
|
||||
// A zero rate is inexpressible off the wire (`Welcome::decode` folds both absence and a
|
||||
// literal 0 to the legacy 48 kHz), but it is the denominator of every conversion above,
|
||||
// so a 0 that ever DID reach here would be a division by zero on the decode thread. One
|
||||
// clamp, at the one place the value enters this module.
|
||||
let rate_hz = if rate_hz == 0 {
|
||||
punktfunk_core::audio::SAMPLE_RATE_HZ
|
||||
} else {
|
||||
rate_hz
|
||||
};
|
||||
SessionAudio {
|
||||
codec,
|
||||
rate_hz,
|
||||
bits,
|
||||
channels: punktfunk_core::audio::normalize_channels(channels) as usize,
|
||||
// Same reasoning as the rate: an old host sends no `audio_frame_us` at all and a
|
||||
// hostile one could send 0, and this number divides nothing but sizes everything.
|
||||
//
|
||||
// Capped at the longest rung of `FRAME_US_LADDER` (which is also the Opus plane's
|
||||
// 5 ms) because the decode scratch is sized from that rung and clamps its copies to
|
||||
// it: an unclamped `frame_us` would let a `Welcome` claim frames the scratch cannot
|
||||
// hold, and the ring would then be reserved for a size the loop can never deliver.
|
||||
// A conforming host only ever names a rung, so this bites nobody real.
|
||||
frame_us: match (is_pcm, frame_us) {
|
||||
(true, us) if us > 0 => us.min(pcm::FRAME_US_LADDER[0]),
|
||||
// The Opus plane's frames are the protocol's fixed 5 ms (host `audio_thread`).
|
||||
_ => OPUS_FRAME_US,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this session runs the lossless `0xD3` plane rather than Opus on `0xC9`.
|
||||
pub(crate) fn is_pcm(&self) -> bool {
|
||||
self.codec == punktfunk_core::quic::AUDIO_CODEC_PCM
|
||||
}
|
||||
|
||||
/// `ms` of audio in interleaved samples at this session's layout — see [`ms_to_samples`].
|
||||
pub(crate) fn ms_samples(&self, ms: u32) -> usize {
|
||||
ms_to_samples(self.rate_hz, self.channels, ms)
|
||||
}
|
||||
|
||||
/// The inverse, for the depths this plane reports to the HUD — see [`samples_to_ms`].
|
||||
pub(crate) fn samples_ms(&self, samples: usize) -> u32 {
|
||||
samples_to_ms(self.rate_hz, self.channels, samples)
|
||||
}
|
||||
|
||||
/// Interleaved samples in ONE frame of this plane — what the ring reserves per queued chunk
|
||||
/// and what the decode-scratch assertion is written against.
|
||||
///
|
||||
/// Taken from [`pcm::samples_per_frame`] rather than re-derived here, because that function is
|
||||
/// the single source of truth for how long a frame is and the host fills its buffers from it.
|
||||
/// The two are only interchangeable when the rate divides the rung: **5 ms of audio at
|
||||
/// 44 100 Hz stereo is 441 interleaved samples, but a 5 ms FRAME carries 440** — 220.5 samples
|
||||
/// per channel do not exist, so the wire floors. Both the ring reserve and the debug assertion
|
||||
/// that guards it mean "exactly one packet", and a self-derived answer would describe a packet
|
||||
/// no host ever sends.
|
||||
pub(crate) fn frame_samples(&self) -> usize {
|
||||
pcm::samples_per_frame(self.rate_hz, self.frame_us, self.channels as u8)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every rate the lossless plane admits, so a conversion that is only exact on one family can
|
||||
/// never be pinned by accident.
|
||||
const RATES: [u32; 5] = [44_100, 48_000, 88_200, 96_000, 176_400];
|
||||
|
||||
fn fmt(rate_hz: u32, channels: usize, frame_us: u32) -> SessionAudio {
|
||||
SessionAudio {
|
||||
codec: punktfunk_core::quic::AUDIO_CODEC_PCM,
|
||||
rate_hz,
|
||||
bits: pcm::BITS_24,
|
||||
channels,
|
||||
frame_us,
|
||||
}
|
||||
}
|
||||
|
||||
/// **The defect, stated as numbers.** `per_ms = rate_hz / 1000 * channels` truncates 44 100 Hz
|
||||
/// stereo to 88 samples per millisecond where it is really 88.2, so everything the plane sizes
|
||||
/// or reports in milliseconds came out 2.3 % wrong: the ring's hard cap 2.3 % SHALLOW, and the
|
||||
/// `buffer_ms` on the HUD 2.3 % DEEP — a plane that measures itself cleanly while being off in
|
||||
/// both directions at once.
|
||||
///
|
||||
/// Paired with [`the_48_khz_family_is_bit_identical_to_the_arithmetic_it_replaced`], which is
|
||||
/// the other half of the claim and passes under BOTH expressions. Restore
|
||||
/// `ms × (rate_hz / 1000 × channels)` and exactly one of the two fails; that asymmetry is the
|
||||
/// whole point, and it is why they are two tests rather than one.
|
||||
#[test]
|
||||
fn the_ms_conversions_are_exact_on_the_rates_that_do_not_divide() {
|
||||
// 44 100 × 2 = 88 200 interleaved samples a second; one second of them is 88 200, not the
|
||||
// 88 000 an integer samples-per-millisecond would have claimed.
|
||||
let f = fmt(44_100, 2, 5_000);
|
||||
assert_eq!(f.ms_samples(1_000), 88_200);
|
||||
assert_eq!(f.samples_ms(88_200), 1_000);
|
||||
// …and the truncated pair, spelled out, so the size of the error is on the record rather
|
||||
// than in a commit message: 88 000 samples and 1 002 ms are what the old code produced.
|
||||
assert_ne!(f.ms_samples(1_000), 1_000 * 88);
|
||||
assert_ne!(f.samples_ms(88_200), 88_200 / 88);
|
||||
|
||||
// 5.1 at 88 200 Hz — the two axes that used to be folded into one constant, both moving.
|
||||
let s = fmt(88_200, 6, 2_000);
|
||||
assert_eq!(s.ms_samples(100), 52_920);
|
||||
assert_eq!(s.samples_ms(52_920), 100);
|
||||
|
||||
// And the top of the ladder, where the truncation is smallest in relative terms and still
|
||||
// wrong: 176 400 Hz × 8 ch is 1 411 200 samples a second, not 1 408 000.
|
||||
let top = fmt(176_400, 8, 1_000);
|
||||
assert_eq!(top.ms_samples(1_000), 1_411_200);
|
||||
}
|
||||
|
||||
/// The other half of the same claim: on 48 000 and 96 000 Hz the new conversions are
|
||||
/// **bit-identical** to the `ms × (rate_hz / 1000 × channels)` they replaced, at every layout
|
||||
/// and every figure the tuning names.
|
||||
///
|
||||
/// Load-bearing, not decorative. Every session anyone has ever run is on this family, and the
|
||||
/// value of "we fixed the arithmetic" depends entirely on nobody's ring having moved by a
|
||||
/// sample while we did it. It holds by construction — `rate × ch` is exactly `1000 × per_ms`
|
||||
/// where the rate divides — and this is that construction asserted rather than argued.
|
||||
///
|
||||
/// It also passes under the OLD expression, which is what makes its partner above a real test:
|
||||
/// plant `per_ms` back and this one still goes green.
|
||||
#[test]
|
||||
fn the_48_khz_family_is_bit_identical_to_the_arithmetic_it_replaced() {
|
||||
for rate in [48_000u32, 96_000] {
|
||||
for ch in [2usize, 6, 8] {
|
||||
let f = fmt(rate, ch, 5_000);
|
||||
let per_ms = (rate as usize / 1000) * ch;
|
||||
for ms in [1u32, 2, 12, 25, 47, 120, 1_000] {
|
||||
assert_eq!(
|
||||
f.ms_samples(ms),
|
||||
ms as usize * per_ms,
|
||||
"{rate} Hz/{ch}ch must be unchanged at {ms} ms"
|
||||
);
|
||||
assert_eq!(
|
||||
f.samples_ms(ms as usize * per_ms),
|
||||
ms,
|
||||
"{rate} Hz/{ch}ch must read back unchanged at {ms} ms"
|
||||
);
|
||||
}
|
||||
// A depth that is NOT a whole number of milliseconds truncates the same way it
|
||||
// always did — the reported `buffer_ms` never rounds up into a figure the ring
|
||||
// does not hold.
|
||||
assert_eq!(f.samples_ms(per_ms * 12 + per_ms / 2), 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The round trip `design/hi-res-audio.md` §4.1 names as the tell that this rework is
|
||||
/// incomplete: a depth expressed in samples and read back as milliseconds must be the
|
||||
/// milliseconds it was built from, at every rate on the ladder and every layout the plane can
|
||||
/// resolve. Core asserts the same property for [`punktfunk_core::audio::JitterPolicy`]; this
|
||||
/// is the half of it this client owns, since the ring's cap and the HUD's `buffer_ms` are
|
||||
/// converted here rather than there.
|
||||
#[test]
|
||||
fn the_shipping_ladder_round_trips_ms_to_samples_at_every_rate() {
|
||||
let t = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
for rate in RATES {
|
||||
for ch in [2usize, 6, 8] {
|
||||
let f = fmt(rate, ch, 2_000);
|
||||
// Every ms figure this preset names — each is a threshold something compares a
|
||||
// sample count against, and a rate that skewed 2.3 % skewed all of them together,
|
||||
// which is exactly what kept the defect invisible.
|
||||
for ms in [
|
||||
t.base_target_ms,
|
||||
t.max_target_ms,
|
||||
t.headroom_ms,
|
||||
t.hard_cap_ms,
|
||||
t.deprime_ms,
|
||||
t.plc_max_ms(),
|
||||
] {
|
||||
assert_eq!(
|
||||
f.samples_ms(f.ms_samples(ms)),
|
||||
ms,
|
||||
"{rate} Hz/{ch}ch lost {ms} ms on the round trip"
|
||||
);
|
||||
}
|
||||
// The conversion itself, against the arithmetic done the honest way rather than
|
||||
// against itself: multiply by the rate and the channels, and only THEN divide.
|
||||
for ms in [1u32, 2, 12, 47, 1_000, 480_000] {
|
||||
assert_eq!(
|
||||
f.ms_samples(ms) as u64,
|
||||
ms as u64 * rate as u64 * ch as u64 / 1000,
|
||||
"{ms} ms at {rate} Hz/{ch}ch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠ Exact is not the same as lossless in both directions, and the difference is worth
|
||||
// stating rather than discovering. A millisecond is 88.2 samples at 44 100 Hz stereo, so
|
||||
// an ms figure that is not a multiple of 5 has no whole-sample answer at all: 1 ms floors
|
||||
// to 88 samples, which reads back as 0. That is a floor of at most ONE SAMPLE — against
|
||||
// the 2.3 % the old arithmetic was out by on EVERY figure, in the same direction,
|
||||
// permanently. Every threshold `JitterTuning::AAUDIO` names is a multiple of 5, which is
|
||||
// why the loop above is exact and this note is a note.
|
||||
let f = fmt(44_100, 2, 5_000);
|
||||
assert_eq!(f.ms_samples(1), 88); // the true 88.2, floored
|
||||
assert_eq!(f.samples_ms(88), 0);
|
||||
assert_eq!(f.ms_samples(15), 1_323, "15 ms of 44.1 kHz stereo");
|
||||
assert_eq!(15 * (44_100 / 1000) * 2, 1_320, "what it used to compute");
|
||||
}
|
||||
|
||||
/// **A frame is not the milliseconds it is labelled with.** At 44 100 Hz a nominal 5 ms frame
|
||||
/// carries 220 samples per channel — 440 interleaved — while 5 ms of *audio* is 441, because
|
||||
/// 220.5 samples per channel do not exist and the wire floors.
|
||||
///
|
||||
/// The ring reserve, the decode-scratch assertion and the policy's shed all mean "exactly one
|
||||
/// packet", so this must come from [`pcm::samples_per_frame`] — the same function the host
|
||||
/// fills its buffers from — and never from a millisecond count. One sample of disagreement on
|
||||
/// an interleaved stream walks the channels around each other.
|
||||
#[test]
|
||||
fn a_frame_is_the_wires_sample_count_not_the_labels_milliseconds() {
|
||||
let f = fmt(44_100, 2, 5_000);
|
||||
assert_eq!(f.frame_samples(), 440, "220 samples per channel, floored");
|
||||
assert_eq!(f.ms_samples(5), 441, "5 ms of AUDIO is 441 interleaved");
|
||||
assert_ne!(f.frame_samples(), f.ms_samples(5));
|
||||
// The real duration of that frame, which is what a `pts_ns` must advance by — 0.23 % short
|
||||
// of the label it negotiated.
|
||||
assert_eq!(pcm::frame_duration_ns(440, 44_100, 2), 4_988_662);
|
||||
|
||||
// Where the rate divides the rung the two agree, which is why nothing noticed for as long
|
||||
// as the ladder was 48/96 kHz only.
|
||||
for rate in [48_000u32, 96_000] {
|
||||
for ch in [2usize, 6, 8] {
|
||||
let f = fmt(rate, ch, 5_000);
|
||||
assert_eq!(f.frame_samples(), f.ms_samples(5), "{rate} Hz/{ch}ch");
|
||||
}
|
||||
}
|
||||
|
||||
// Surround sizes from the RESOLVED channel count, not from a stereo assumption: a 5.1
|
||||
// frame is three times a stereo one and the ring is reserved from it.
|
||||
let stereo = fmt(48_000, 2, 2_000);
|
||||
let five_one = fmt(48_000, 6, 2_000);
|
||||
assert_eq!(stereo.frame_samples(), 192);
|
||||
assert_eq!(five_one.frame_samples(), 576);
|
||||
}
|
||||
|
||||
/// A `Welcome` this client cannot trust must not become a division fault or a buffer sized
|
||||
/// from garbage on the decode thread. Absence, a literal zero and an over-long frame all have
|
||||
/// defined answers, and they are the safe ones.
|
||||
#[test]
|
||||
fn a_degenerate_welcome_clamps_instead_of_dividing_by_zero() {
|
||||
const OPUS: u8 = punktfunk_core::quic::AUDIO_CODEC_OPUS;
|
||||
const PCM: u8 = punktfunk_core::quic::AUDIO_CODEC_PCM;
|
||||
|
||||
// The ordinary session: a pre-lossless host sends none of these fields, and every absent
|
||||
// one has to land on exactly what the plane has always been.
|
||||
let legacy = SessionAudio::resolved(OPUS, 0, 0, 0, 0);
|
||||
assert!(!legacy.is_pcm());
|
||||
assert_eq!(legacy.rate_hz, punktfunk_core::audio::SAMPLE_RATE_HZ);
|
||||
assert_eq!(legacy.channels, 2);
|
||||
assert_eq!(legacy.frame_us, OPUS_FRAME_US);
|
||||
|
||||
// `audio_frame_us` is a `0xD3` field and must not be honoured on the Opus plane, whose
|
||||
// frames the protocol fixes at 5 ms — a host that sent one anyway would otherwise resize
|
||||
// this client's ring for frames it never sends.
|
||||
assert_eq!(
|
||||
SessionAudio::resolved(OPUS, 48_000, 16, 2, 2_000).frame_us,
|
||||
OPUS_FRAME_US
|
||||
);
|
||||
// …and on `0xD3` a frame longer than the ladder's top rung is capped there, because the
|
||||
// decode scratch is sized from that rung and clamps its copies to it.
|
||||
let overlong = SessionAudio::resolved(PCM, 96_000, 24, 6, 60_000);
|
||||
assert!(overlong.is_pcm());
|
||||
assert_eq!(overlong.frame_us, pcm::FRAME_US_LADDER[0]);
|
||||
assert_eq!(overlong.channels, 6);
|
||||
// A layout off the wire is normalized rather than trusted: 3 channels is not a layout the
|
||||
// decoder or AAudio can be opened with.
|
||||
assert_eq!(
|
||||
SessionAudio::resolved(PCM, 44_100, 24, 3, 5_000).channels,
|
||||
2
|
||||
);
|
||||
|
||||
// The conversions still have to survive a 0 that reached them some other way, because they
|
||||
// run on a realtime-adjacent thread that may not panic.
|
||||
let broken = fmt(0, 0, 0);
|
||||
assert_eq!(broken.ms_samples(10), 0);
|
||||
assert_eq!(broken.samples_ms(480), 480_000);
|
||||
assert_eq!(broken.frame_samples(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! The dozen EGL entry points the console host needs, hand-declared. `libEGL.so` is already
|
||||
//! a `NEEDED` of this `.so` (skia-bindings links it for Skia's GL backend), so a plain
|
||||
//! `#[link]` costs nothing new; a binding crate would be a dependency for twelve functions
|
||||
//! whose signatures have not changed since 2008.
|
||||
//!
|
||||
//! One display, one context, one window surface at a time: the console draws into the
|
||||
//! `SurfaceView` Kotlin hands over, and re-creates only the surface when that view comes and
|
||||
//! goes. The context (and Skia's `DirectContext` on it) survives across surfaces so the
|
||||
//! poster/glyph caches survive a trip through the stream.
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
|
||||
pub(super) type EGLDisplay = *mut c_void;
|
||||
pub(super) type EGLConfig = *mut c_void;
|
||||
pub(super) type EGLContext = *mut c_void;
|
||||
pub(super) type EGLSurface = *mut c_void;
|
||||
type EGLNativeWindowType = *mut c_void;
|
||||
type EGLBoolean = u32;
|
||||
type EGLint = i32;
|
||||
|
||||
const EGL_DEFAULT_DISPLAY: *mut c_void = std::ptr::null_mut();
|
||||
const EGL_NO_DISPLAY: EGLDisplay = std::ptr::null_mut();
|
||||
const EGL_NO_CONTEXT: EGLContext = std::ptr::null_mut();
|
||||
const EGL_NO_SURFACE: EGLSurface = std::ptr::null_mut();
|
||||
const EGL_TRUE: EGLBoolean = 1;
|
||||
|
||||
const EGL_NONE: EGLint = 0x3038;
|
||||
const EGL_SURFACE_TYPE: EGLint = 0x3033;
|
||||
const EGL_WINDOW_BIT: EGLint = 0x0004;
|
||||
const EGL_RENDERABLE_TYPE: EGLint = 0x3040;
|
||||
const EGL_OPENGL_ES2_BIT: EGLint = 0x0004;
|
||||
const EGL_OPENGL_ES3_BIT: EGLint = 0x0040;
|
||||
const EGL_RED_SIZE: EGLint = 0x3024;
|
||||
const EGL_GREEN_SIZE: EGLint = 0x3023;
|
||||
const EGL_BLUE_SIZE: EGLint = 0x3022;
|
||||
const EGL_ALPHA_SIZE: EGLint = 0x3021;
|
||||
const EGL_STENCIL_SIZE: EGLint = 0x3026;
|
||||
const EGL_DEPTH_SIZE: EGLint = 0x3025;
|
||||
const EGL_SAMPLES: EGLint = 0x3031;
|
||||
const EGL_CONTEXT_CLIENT_VERSION: EGLint = 0x3098;
|
||||
const EGL_WIDTH: EGLint = 0x3057;
|
||||
const EGL_HEIGHT: EGLint = 0x3056;
|
||||
|
||||
#[link(name = "EGL")]
|
||||
unsafe extern "C" {
|
||||
fn eglGetDisplay(display_id: *mut c_void) -> EGLDisplay;
|
||||
fn eglInitialize(dpy: EGLDisplay, major: *mut EGLint, minor: *mut EGLint) -> EGLBoolean;
|
||||
fn eglChooseConfig(
|
||||
dpy: EGLDisplay,
|
||||
attrib_list: *const EGLint,
|
||||
configs: *mut EGLConfig,
|
||||
config_size: EGLint,
|
||||
num_config: *mut EGLint,
|
||||
) -> EGLBoolean;
|
||||
fn eglGetConfigAttrib(
|
||||
dpy: EGLDisplay,
|
||||
config: EGLConfig,
|
||||
attribute: EGLint,
|
||||
value: *mut EGLint,
|
||||
) -> EGLBoolean;
|
||||
fn eglCreateContext(
|
||||
dpy: EGLDisplay,
|
||||
config: EGLConfig,
|
||||
share_context: EGLContext,
|
||||
attrib_list: *const EGLint,
|
||||
) -> EGLContext;
|
||||
fn eglCreateWindowSurface(
|
||||
dpy: EGLDisplay,
|
||||
config: EGLConfig,
|
||||
win: EGLNativeWindowType,
|
||||
attrib_list: *const EGLint,
|
||||
) -> EGLSurface;
|
||||
fn eglMakeCurrent(
|
||||
dpy: EGLDisplay,
|
||||
draw: EGLSurface,
|
||||
read: EGLSurface,
|
||||
ctx: EGLContext,
|
||||
) -> EGLBoolean;
|
||||
fn eglSwapBuffers(dpy: EGLDisplay, surface: EGLSurface) -> EGLBoolean;
|
||||
fn eglSwapInterval(dpy: EGLDisplay, interval: EGLint) -> EGLBoolean;
|
||||
fn eglQuerySurface(
|
||||
dpy: EGLDisplay,
|
||||
surface: EGLSurface,
|
||||
attribute: EGLint,
|
||||
value: *mut EGLint,
|
||||
) -> EGLBoolean;
|
||||
fn eglDestroySurface(dpy: EGLDisplay, surface: EGLSurface) -> EGLBoolean;
|
||||
fn eglDestroyContext(dpy: EGLDisplay, ctx: EGLContext) -> EGLBoolean;
|
||||
fn eglGetError() -> EGLint;
|
||||
}
|
||||
|
||||
/// The GL client version the context was created for — Skia's `Interface::new_native()`
|
||||
/// discovers the rest itself, but the SkSL mesh backdrop compiles under ES2 restrictions on
|
||||
/// a 2.0 context, and the host wants to know which world it is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum GlesVersion {
|
||||
Es2,
|
||||
Es3,
|
||||
}
|
||||
|
||||
/// The display + config + context, created once per host and kept for its lifetime.
|
||||
pub(super) struct EglContext {
|
||||
display: EGLDisplay,
|
||||
config: EGLConfig,
|
||||
context: EGLContext,
|
||||
pub(super) version: GlesVersion,
|
||||
/// The config's stencil depth — what Skia's `BackendRenderTarget` for FBO 0 declares.
|
||||
pub(super) stencil_bits: i32,
|
||||
/// The config's MSAA sample count (0 = none) — likewise.
|
||||
pub(super) samples: i32,
|
||||
}
|
||||
|
||||
// SAFETY: EGL handles are process-wide tokens; the render thread is the only thread that
|
||||
// makes the context current, and `EglContext` is only ever moved onto it (never shared).
|
||||
unsafe impl Send for EglContext {}
|
||||
|
||||
impl EglContext {
|
||||
/// Initialise the default display and create an ES 3 context, falling back to ES 2 on
|
||||
/// the boxes that have nothing newer. RGBA8888 with an 8-bit stencil (Skia's path
|
||||
/// rendering wants one), no depth, no MSAA (the shell anti-aliases in Skia).
|
||||
pub(super) fn new() -> Result<EglContext> {
|
||||
// SAFETY: plain EGL calls with valid arguments; every handle is checked before use.
|
||||
unsafe {
|
||||
let display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if display == EGL_NO_DISPLAY {
|
||||
bail!("eglGetDisplay: no default display");
|
||||
}
|
||||
let (mut major, mut minor) = (0, 0);
|
||||
if eglInitialize(display, &mut major, &mut minor) != EGL_TRUE {
|
||||
bail!("eglInitialize: 0x{:x}", eglGetError());
|
||||
}
|
||||
for (version, renderable) in [
|
||||
(GlesVersion::Es3, EGL_OPENGL_ES3_BIT),
|
||||
(GlesVersion::Es2, EGL_OPENGL_ES2_BIT),
|
||||
] {
|
||||
let attribs = [
|
||||
EGL_SURFACE_TYPE,
|
||||
EGL_WINDOW_BIT,
|
||||
EGL_RENDERABLE_TYPE,
|
||||
renderable,
|
||||
EGL_RED_SIZE,
|
||||
8,
|
||||
EGL_GREEN_SIZE,
|
||||
8,
|
||||
EGL_BLUE_SIZE,
|
||||
8,
|
||||
EGL_ALPHA_SIZE,
|
||||
8,
|
||||
EGL_STENCIL_SIZE,
|
||||
8,
|
||||
EGL_DEPTH_SIZE,
|
||||
0,
|
||||
EGL_NONE,
|
||||
];
|
||||
let mut config: EGLConfig = std::ptr::null_mut();
|
||||
let mut n: EGLint = 0;
|
||||
if eglChooseConfig(display, attribs.as_ptr(), &mut config, 1, &mut n) != EGL_TRUE
|
||||
|| n < 1
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let client_version = match version {
|
||||
GlesVersion::Es3 => 3,
|
||||
GlesVersion::Es2 => 2,
|
||||
};
|
||||
let ctx_attribs = [EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE];
|
||||
let context =
|
||||
eglCreateContext(display, config, EGL_NO_CONTEXT, ctx_attribs.as_ptr());
|
||||
if context == EGL_NO_CONTEXT {
|
||||
continue;
|
||||
}
|
||||
let attr = |a: EGLint| {
|
||||
let mut v: EGLint = 0;
|
||||
if eglGetConfigAttrib(display, config, a, &mut v) == EGL_TRUE {
|
||||
v
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let stencil_bits = attr(EGL_STENCIL_SIZE);
|
||||
let samples = attr(EGL_SAMPLES);
|
||||
log::info!(
|
||||
"console: EGL {major}.{minor}, GLES {client_version} context, stencil {stencil_bits}, samples {samples}"
|
||||
);
|
||||
return Ok(EglContext {
|
||||
display,
|
||||
config,
|
||||
context,
|
||||
version,
|
||||
stencil_bits,
|
||||
samples,
|
||||
});
|
||||
}
|
||||
bail!(
|
||||
"no EGL config/context for GLES 3 or 2 (0x{:x})",
|
||||
eglGetError()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A window surface over `window` (an `ANativeWindow*`), made current on the calling
|
||||
/// thread with a vsync-locked swap interval. Returns the surface and its pixel size.
|
||||
pub(super) fn window_surface(&self, window: *mut c_void) -> Result<EglSurface> {
|
||||
// SAFETY: `window` is a live ANativeWindow the caller holds a reference to for the
|
||||
// surface's lifetime; the display/config/context are this object's own.
|
||||
unsafe {
|
||||
let surface =
|
||||
eglCreateWindowSurface(self.display, self.config, window, std::ptr::null());
|
||||
if surface == EGL_NO_SURFACE {
|
||||
bail!("eglCreateWindowSurface: 0x{:x}", eglGetError());
|
||||
}
|
||||
if eglMakeCurrent(self.display, surface, surface, self.context) != EGL_TRUE {
|
||||
let e = eglGetError();
|
||||
eglDestroySurface(self.display, surface);
|
||||
bail!("eglMakeCurrent: 0x{e:x}");
|
||||
}
|
||||
// 1 = present on the panel's cadence, never faster: `eglSwapBuffers` blocks and
|
||||
// paces the render loop, which is the whole frame-timing story of this host.
|
||||
eglSwapInterval(self.display, 1);
|
||||
let (mut w, mut h) = (0, 0);
|
||||
eglQuerySurface(self.display, surface, EGL_WIDTH, &mut w);
|
||||
eglQuerySurface(self.display, surface, EGL_HEIGHT, &mut h);
|
||||
Ok(EglSurface {
|
||||
display: self.display,
|
||||
surface,
|
||||
width: w.max(1) as u32,
|
||||
height: h.max(1) as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the current surface from this thread (before the surface is destroyed).
|
||||
pub(super) fn release_current(&self) {
|
||||
// SAFETY: valid display; NO_SURFACE/NO_CONTEXT is the documented "unbind" call.
|
||||
unsafe {
|
||||
eglMakeCurrent(self.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EglContext {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the context is this object's own and nothing is current on this thread once
|
||||
// the host has released its surface (the render loop releases before it exits).
|
||||
unsafe {
|
||||
eglDestroyContext(self.display, self.context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One window surface. Dropping it destroys the EGL surface — the caller must have released
|
||||
/// it from the current thread first ([`EglContext::release_current`]).
|
||||
pub(super) struct EglSurface {
|
||||
display: EGLDisplay,
|
||||
surface: EGLSurface,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
}
|
||||
|
||||
// SAFETY: as for `EglContext` — moved onto the render thread, never shared.
|
||||
unsafe impl Send for EglSurface {}
|
||||
|
||||
impl EglSurface {
|
||||
/// Present. `Err` = the surface is gone underneath us (the window was destroyed) — the
|
||||
/// caller drops it and waits for the next one.
|
||||
pub(super) fn swap(&self) -> Result<()> {
|
||||
// SAFETY: valid display + surface owned by this object.
|
||||
if unsafe { eglSwapBuffers(self.display, self.surface) } == EGL_TRUE {
|
||||
Ok(())
|
||||
} else {
|
||||
// SAFETY: plain query.
|
||||
Err(anyhow!("eglSwapBuffers: 0x{:x}", unsafe { eglGetError() }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the surface's pixel size (after a `surfaceChanged`).
|
||||
pub(super) fn refresh_size(&mut self) {
|
||||
let (mut w, mut h) = (0, 0);
|
||||
// SAFETY: valid display + surface owned by this object.
|
||||
unsafe {
|
||||
eglQuerySurface(self.display, self.surface, EGL_WIDTH, &mut w);
|
||||
eglQuerySurface(self.display, self.surface, EGL_HEIGHT, &mut h);
|
||||
}
|
||||
self.width = w.max(1) as u32;
|
||||
self.height = h.max(1) as u32;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EglSurface {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the surface is this object's own; the render loop released it from the
|
||||
// thread before dropping.
|
||||
unsafe {
|
||||
eglDestroySurface(self.display, self.surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Skia over the host's EGL context: one `DirectContext` for the host's lifetime, and a
|
||||
//! `Surface` wrapping framebuffer 0 of whatever window surface is current, re-wrapped when
|
||||
//! the window's size changes.
|
||||
|
||||
use super::egl::EglContext;
|
||||
use anyhow::{anyhow, Result};
|
||||
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
|
||||
use skia_safe::{ColorType, Surface};
|
||||
|
||||
/// GL_RGBA8 — the sized internal format of the RGBA8888 EGL config's default framebuffer.
|
||||
const GL_RGBA8: u32 = 0x8058;
|
||||
|
||||
pub(super) struct Gpu {
|
||||
pub(super) context: DirectContext,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// The Skia context over the (already current) EGL context. `cache_bytes` is the resource
|
||||
/// budget the console asked for (posters, glyph atlases — see `ConsoleOptions`).
|
||||
pub(super) fn new(_egl: &EglContext, cache_bytes: usize) -> Result<Gpu> {
|
||||
// Skia's native GL interface on Android assembles itself over `eglGetProcAddress`.
|
||||
let interface = gpu::gl::Interface::new_native()
|
||||
.ok_or_else(|| anyhow!("Skia: no native GL interface (is a GLES context current?)"))?;
|
||||
let mut context = gpu::direct_contexts::make_gl(interface, None)
|
||||
.ok_or_else(|| anyhow!("Skia: DirectContext over GLES failed"))?;
|
||||
context.set_resource_cache_limit(cache_bytes);
|
||||
log::info!(
|
||||
"console: Skia GL DirectContext, {} MB resource budget",
|
||||
cache_bytes >> 20
|
||||
);
|
||||
Ok(Gpu { context })
|
||||
}
|
||||
|
||||
/// A Skia surface over the current window surface's default framebuffer.
|
||||
pub(super) fn wrap_window(
|
||||
&mut self,
|
||||
egl: &EglContext,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Surface> {
|
||||
let fb = gpu::gl::FramebufferInfo {
|
||||
fboid: 0,
|
||||
format: GL_RGBA8,
|
||||
protected: gpu::Protected::No,
|
||||
};
|
||||
let samples = usize::try_from(egl.samples).unwrap_or(0);
|
||||
let stencil = usize::try_from(egl.stencil_bits).unwrap_or(0);
|
||||
let target = gpu::backend_render_targets::make_gl(
|
||||
(width as i32, height as i32),
|
||||
if samples > 1 { Some(samples) } else { None },
|
||||
stencil,
|
||||
fb,
|
||||
);
|
||||
gpu::surfaces::wrap_backend_render_target(
|
||||
&mut self.context,
|
||||
&target,
|
||||
SurfaceOrigin::BottomLeft,
|
||||
ColorType::RGBA8888,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.ok_or_else(|| anyhow!("Skia: wrap FBO 0 as a surface ({width}×{height})"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
//! The console host proper: one render thread that owns the EGL context, the Skia
|
||||
//! `DirectContext` and the [`Console`], paced by `eglSwapBuffers` while a surface is up
|
||||
//! and parked while there is none. Everything else — Kotlin's input, surface lifecycle,
|
||||
//! session edges — arrives through a command queue and is applied on that thread; what the
|
||||
//! console raises (actions, haptic pulses, editing state, settings to persist) leaves
|
||||
//! through an event queue a Kotlin poll thread blocks on.
|
||||
//!
|
||||
//! The model side needs none of this: `ConsoleShared`/`LibraryShared` are lock-guarded and
|
||||
//! written straight from JNI, `ConsoleBus` is drained straight from JNI. Only the shell
|
||||
//! itself is single-threaded, and this thread is that thread.
|
||||
|
||||
use super::egl::{EglContext, EglSurface, GlesVersion};
|
||||
use super::gpu::Gpu;
|
||||
use anyhow::{bail, Result};
|
||||
use ndk::native_window::NativeWindow;
|
||||
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
|
||||
use pf_console_ui::{
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
|
||||
};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A session edge as Kotlin reports it — `SessionPhase` borrows its strings, so the queue
|
||||
/// carries an owned twin.
|
||||
pub(super) enum Phase {
|
||||
Connecting,
|
||||
Streaming,
|
||||
Failed(String),
|
||||
Ended(Option<String>),
|
||||
Reconnecting(String),
|
||||
}
|
||||
|
||||
/// What Kotlin asks the render thread to do.
|
||||
pub(super) enum Cmd {
|
||||
Menu(MenuEvent),
|
||||
/// The raw pad, whenever it changes; the thread feeds `MenuNav` with the LAST sample every
|
||||
/// frame (repeats need a clock) and once on arrival (a press must not wait for a frame).
|
||||
PadSample(MenuSample),
|
||||
Pointer(PointerInput),
|
||||
Key {
|
||||
key: Key,
|
||||
shift: bool,
|
||||
repeat: bool,
|
||||
},
|
||||
Text(String),
|
||||
Phase(Phase),
|
||||
Navigate(ConsoleEntry),
|
||||
SurfaceCreated(NativeWindow),
|
||||
SurfaceChanged,
|
||||
/// Acknowledged through `Shared::surface_gen` once the EGL surface is really gone —
|
||||
/// Kotlin's `surfaceDestroyed` must not return before that.
|
||||
SurfaceDestroyed,
|
||||
Viewport {
|
||||
insets: Insets,
|
||||
scale: Option<f64>,
|
||||
},
|
||||
Pads {
|
||||
label: Option<String>,
|
||||
pref: Option<GamepadPref>,
|
||||
pads: Vec<PadInfo>,
|
||||
},
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// What the render thread raises for Kotlin.
|
||||
pub(super) enum HostEvent {
|
||||
Action(OverlayAction),
|
||||
Pulse(MenuPulse),
|
||||
Editing(bool),
|
||||
/// The shell saved settings: here is the whole snapshot to persist.
|
||||
Settings(Box<pf_client_core::trust::Settings>),
|
||||
/// The GLES generation the context came up with — Kotlin logs it, nothing more.
|
||||
Gles(GlesVersion),
|
||||
/// The render thread died (EGL/Skia init failed). Kotlin falls back to its own console.
|
||||
Dead(String),
|
||||
}
|
||||
|
||||
impl HostEvent {
|
||||
/// The JSON Kotlin parses. Hand-rolled for the small variants; the two model payloads
|
||||
/// ride serde.
|
||||
pub(super) fn to_json(&self) -> String {
|
||||
match self {
|
||||
HostEvent::Action(a) => format!(
|
||||
"{{\"action\":{}}}",
|
||||
serde_json::to_string(a).unwrap_or_else(|_| "null".into())
|
||||
),
|
||||
HostEvent::Pulse(p) => format!(
|
||||
"{{\"pulse\":\"{}\"}}",
|
||||
match p {
|
||||
MenuPulse::Move => "move",
|
||||
MenuPulse::Confirm => "confirm",
|
||||
MenuPulse::Boundary => "boundary",
|
||||
}
|
||||
),
|
||||
HostEvent::Editing(e) => format!("{{\"editing\":{e}}}"),
|
||||
HostEvent::Settings(s) => format!(
|
||||
"{{\"settings\":{}}}",
|
||||
serde_json::to_string(s).unwrap_or_else(|_| "null".into())
|
||||
),
|
||||
HostEvent::Gles(v) => format!(
|
||||
"{{\"gles\":{}}}",
|
||||
match v {
|
||||
GlesVersion::Es2 => 2,
|
||||
GlesVersion::Es3 => 3,
|
||||
}
|
||||
),
|
||||
HostEvent::Dead(msg) => format!(
|
||||
"{{\"dead\":{}}}",
|
||||
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".into())
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Shared {
|
||||
inbox: Mutex<VecDeque<Cmd>>,
|
||||
inbox_cv: Condvar,
|
||||
events: Mutex<VecDeque<HostEvent>>,
|
||||
events_cv: Condvar,
|
||||
/// Bumped by the render thread each time it has torn a surface down; `SurfaceDestroyed`
|
||||
/// waits for the bump.
|
||||
surface_gen: Mutex<u64>,
|
||||
surface_cv: Condvar,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn new() -> Shared {
|
||||
Shared {
|
||||
inbox: Mutex::new(VecDeque::new()),
|
||||
inbox_cv: Condvar::new(),
|
||||
events: Mutex::new(VecDeque::new()),
|
||||
events_cv: Condvar::new(),
|
||||
surface_gen: Mutex::new(0),
|
||||
surface_cv: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn send(&self, cmd: Cmd) {
|
||||
self.inbox
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push_back(cmd);
|
||||
self.inbox_cv.notify_one();
|
||||
}
|
||||
|
||||
fn emit(&self, ev: HostEvent) {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push_back(ev);
|
||||
self.events_cv.notify_one();
|
||||
}
|
||||
|
||||
/// Kotlin's poll: the next event, waiting up to `timeout` for one.
|
||||
pub(super) fn next_event(&self, timeout: Duration) -> Option<HostEvent> {
|
||||
let mut q = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if q.is_empty() {
|
||||
let (guard, _) = self
|
||||
.events_cv
|
||||
.wait_timeout(q, timeout)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
q = guard;
|
||||
}
|
||||
q.pop_front()
|
||||
}
|
||||
|
||||
/// Ask for the surface to go and wait (bounded) until it has.
|
||||
pub(super) fn destroy_surface_blocking(&self) {
|
||||
let before = *self
|
||||
.surface_gen
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
self.send(Cmd::SurfaceDestroyed);
|
||||
let g = self
|
||||
.surface_gen
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Bounded: a render thread that died mid-frame must not hang the UI thread forever —
|
||||
// by then the EGL surface is gone with it anyway.
|
||||
let _ = self
|
||||
.surface_cv
|
||||
.wait_timeout_while(g, Duration::from_secs(2), |g| *g == before)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
|
||||
fn ack_surface_gone(&self) {
|
||||
*self
|
||||
.surface_gen
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
|
||||
self.surface_cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// The host as the JNI layer holds it.
|
||||
pub(super) struct ConsoleHost {
|
||||
pub(super) shared: Arc<Shared>,
|
||||
pub(super) handles: ConsoleHandles,
|
||||
pub(super) store: Arc<SnapshotStore>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ConsoleHost {
|
||||
/// Start the render thread; it builds the console (shell + fonts — Skia handles, so it
|
||||
/// cannot be built here and sent) and parks until a surface arrives. A build failure
|
||||
/// arrives as a `Dead` event.
|
||||
pub(super) fn start(
|
||||
opts: ConsoleOptions,
|
||||
entry: ConsoleEntry,
|
||||
store: Arc<SnapshotStore>,
|
||||
) -> ConsoleHost {
|
||||
let shared = Arc::new(Shared::new());
|
||||
let handles = ConsoleHandles::new();
|
||||
let thread_shared = shared.clone();
|
||||
let thread_store = store.clone();
|
||||
let thread_handles = handles.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-console".into())
|
||||
.spawn(move || {
|
||||
let run = || -> Result<()> {
|
||||
let console = Console::new(opts, entry, &thread_handles)?;
|
||||
render_loop(console, thread_shared.clone(), thread_store)
|
||||
};
|
||||
if let Err(e) = run() {
|
||||
log::error!("console: render thread ended: {e:#}");
|
||||
thread_shared.emit(HostEvent::Dead(format!("{e:#}")));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
ConsoleHost {
|
||||
shared,
|
||||
handles,
|
||||
store,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop(mut self) {
|
||||
self.shared.send(Cmd::Quit);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No input for this long = the console is being looked at, not used — halve the redraw
|
||||
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
|
||||
/// afterglow at full smoothness and only calms a genuinely parked screen.
|
||||
const IDLE_AFTER: Duration = Duration::from_secs(60);
|
||||
/// One extra ~vsync period per frame while idle: 60 Hz → ~30, 120 Hz → ~40.
|
||||
const IDLE_FRAME_STEP: Duration = Duration::from_millis(16);
|
||||
|
||||
/// The render thread. Owns EGL + Skia + the console; runs until `Cmd::Quit`.
|
||||
fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotStore>) -> Result<()> {
|
||||
let egl = EglContext::new()?;
|
||||
shared.emit(HostEvent::Gles(egl.version));
|
||||
let mut gpu: Option<Gpu> = None;
|
||||
let mut window: Option<NativeWindow> = None;
|
||||
let mut surface: Option<EglSurface> = None;
|
||||
let mut skia: Option<(skia_safe::Surface, u32, u32)> = None;
|
||||
let mut nav = MenuNav::new();
|
||||
let mut sample = MenuSample::default();
|
||||
let mut insets = Insets::default();
|
||||
let mut scale: Option<f64> = None;
|
||||
let mut pad_label: Option<String> = None;
|
||||
let mut pad_pref: Option<GamepadPref> = None;
|
||||
let mut pads: Vec<PadInfo> = Vec::new();
|
||||
let mut was_editing = console.editing();
|
||||
let mut saved_gen = store.saved_gen();
|
||||
let mut menu_out: Vec<MenuEvent> = Vec::new();
|
||||
// When the last input arrived — the idle throttle's clock (see the draw site below).
|
||||
let mut last_input = Instant::now();
|
||||
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
|
||||
// torn down mid-create); a run of them is a context that is not coming back — most likely
|
||||
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
|
||||
// failure alone is logged, the loop retries, and the screen stays a gray never-painted
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
let cmds: Vec<Cmd> = {
|
||||
let mut q = shared
|
||||
.inbox
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if surface.is_none() && q.is_empty() {
|
||||
let (guard, _) = shared
|
||||
.inbox_cv
|
||||
.wait_timeout(q, Duration::from_millis(500))
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
q = guard;
|
||||
}
|
||||
q.drain(..).collect()
|
||||
};
|
||||
let mut poll_now = false;
|
||||
for cmd in cmds {
|
||||
match cmd {
|
||||
Cmd::Quit => {
|
||||
// Release in order: the Skia surface, then the current binding, then (on
|
||||
// return) the EGL surface + window + context.
|
||||
drop(skia.take());
|
||||
if surface.is_some() {
|
||||
egl.release_current();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Cmd::Menu(ev) => {
|
||||
last_input = Instant::now();
|
||||
if let Some(p) = console.menu(ev) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
Cmd::PadSample(s) => {
|
||||
last_input = Instant::now();
|
||||
sample = s;
|
||||
poll_now = true;
|
||||
}
|
||||
Cmd::Pointer(p) => {
|
||||
last_input = Instant::now();
|
||||
console.pointer(p);
|
||||
}
|
||||
Cmd::Key { key, shift, repeat } => {
|
||||
last_input = Instant::now();
|
||||
console.key(key, shift, repeat);
|
||||
}
|
||||
Cmd::Text(t) => {
|
||||
last_input = Instant::now();
|
||||
console.text(&t);
|
||||
}
|
||||
Cmd::Phase(ph) => {
|
||||
match &ph {
|
||||
Phase::Connecting => console.session_phase(SessionPhase::Connecting),
|
||||
Phase::Streaming => console.session_phase(SessionPhase::Streaming),
|
||||
Phase::Failed(m) => console.session_phase(SessionPhase::Failed(m)),
|
||||
Phase::Ended(r) => {
|
||||
console.session_phase(SessionPhase::Ended(r.as_deref()));
|
||||
}
|
||||
Phase::Reconnecting(m) => {
|
||||
console.session_phase(SessionPhase::Reconnecting(m));
|
||||
}
|
||||
}
|
||||
// Coming back from a stream: whatever is held on the pad now (the chord
|
||||
// that ended it) must be released before it can act here.
|
||||
if matches!(ph, Phase::Ended(_) | Phase::Failed(_)) {
|
||||
nav.reset();
|
||||
}
|
||||
}
|
||||
Cmd::Navigate(entry) => console.navigate(entry),
|
||||
Cmd::SurfaceCreated(w) => {
|
||||
// A surface arriving while one is up: replace it (Kotlin re-created the
|
||||
// view without a destroy in between — treat as destroy + create).
|
||||
if surface.is_some() {
|
||||
skia = None;
|
||||
egl.release_current();
|
||||
surface = None;
|
||||
window = None;
|
||||
}
|
||||
match egl.window_surface(w.ptr().as_ptr().cast()) {
|
||||
Ok(s) => {
|
||||
if gpu.is_none() {
|
||||
gpu = Some(Gpu::new(&egl, console.gpu_cache_bytes())?);
|
||||
}
|
||||
surface = Some(s);
|
||||
window = Some(w);
|
||||
gl_failures = 0;
|
||||
// A fresh surface is a fresh entry: snapshot the pad so a button
|
||||
// still held from before does not fire into the first frame.
|
||||
nav.reset();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: window surface: {e:#}");
|
||||
gl_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::SurfaceChanged => {
|
||||
if let Some(s) = surface.as_mut() {
|
||||
s.refresh_size();
|
||||
}
|
||||
}
|
||||
Cmd::SurfaceDestroyed => {
|
||||
skia = None;
|
||||
if surface.is_some() {
|
||||
egl.release_current();
|
||||
}
|
||||
surface = None;
|
||||
window = None;
|
||||
shared.ack_surface_gone();
|
||||
}
|
||||
Cmd::Viewport {
|
||||
insets: i,
|
||||
scale: s,
|
||||
} => {
|
||||
insets = i;
|
||||
scale = s;
|
||||
}
|
||||
Cmd::Pads {
|
||||
label,
|
||||
pref,
|
||||
pads: p,
|
||||
} => {
|
||||
pad_label = label;
|
||||
pad_pref = pref;
|
||||
pads = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `window` is only held so the ANativeWindow outlives the EGL surface over it.
|
||||
let _ = &window;
|
||||
|
||||
// The pad, through the shared synthesizer: once per frame for repeats, plus once
|
||||
// right now if a sample just arrived.
|
||||
if poll_now || surface.is_some() {
|
||||
menu_out.clear();
|
||||
nav.poll(&sample, Instant::now(), &mut menu_out);
|
||||
for ev in menu_out.drain(..) {
|
||||
if let Some(p) = console.menu(ev) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw, if there is somewhere to draw.
|
||||
// ponytail: half-rate after 60 s without input — one extra frame period between
|
||||
// swaps, so an idle carousel stops redrawing a phone's panel at its full rate
|
||||
// (the aurora still breathes, at half tempo). Any input restores full rate on
|
||||
// its own frame; damage-driven rendering if a TV box ever needs more.
|
||||
if last_input.elapsed() >= IDLE_AFTER {
|
||||
std::thread::sleep(IDLE_FRAME_STEP);
|
||||
}
|
||||
if let (Some(s), Some(g)) = (surface.as_mut(), gpu.as_mut()) {
|
||||
let (w, h) = (s.width, s.height);
|
||||
let need_wrap = match &skia {
|
||||
Some((_, sw, sh)) => *sw != w || *sh != h,
|
||||
None => true,
|
||||
};
|
||||
if need_wrap {
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => {
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
gl_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((surf, _, _)) = skia.as_mut() {
|
||||
let viewport = Viewport {
|
||||
width: w,
|
||||
height: h,
|
||||
insets,
|
||||
scale,
|
||||
};
|
||||
console.frame(
|
||||
surf.canvas(),
|
||||
&viewport,
|
||||
pad_label.as_deref(),
|
||||
pad_pref,
|
||||
&pads,
|
||||
);
|
||||
g.context.flush_and_submit();
|
||||
if let Err(e) = s.swap() {
|
||||
// The window went away under us; wait for the next surface.
|
||||
log::warn!("console: {e:#} — dropping the surface");
|
||||
skia = None;
|
||||
egl.release_current();
|
||||
surface = None;
|
||||
window = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gl_failures >= GL_FAILURE_LIMIT {
|
||||
// Same release order as `Cmd::Quit`: the Skia surface, the current binding, then (on
|
||||
// return) the EGL surface + window + context drop.
|
||||
drop(skia.take());
|
||||
if surface.is_some() {
|
||||
egl.release_current();
|
||||
}
|
||||
bail!("GL surface failed {gl_failures} times in a row — giving the screen back");
|
||||
}
|
||||
|
||||
// Publish what the console raised.
|
||||
while let Some(a) = console.take_action() {
|
||||
shared.emit(HostEvent::Action(a));
|
||||
}
|
||||
let editing = console.editing();
|
||||
if editing != was_editing {
|
||||
was_editing = editing;
|
||||
shared.emit(HostEvent::Editing(editing));
|
||||
}
|
||||
if store.saved_gen() != saved_gen {
|
||||
let (settings, current_gen) = store.snapshot();
|
||||
saved_gen = current_gen;
|
||||
shared.emit(HostEvent::Settings(Box::new(settings)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
//! The Skia console UI on Android (design/android-skia-console-port.md, WP3): the same
|
||||
//! `pf-console-ui` shell the Linux/Windows session binary shows, drawn by this crate onto
|
||||
//! the `SurfaceView` Kotlin hands over, through Skia's GL backend on an EGL context this
|
||||
//! module owns. Kotlin keeps the services (trust store, settings, discovery, the mTLS
|
||||
//! library fetch, WoL, pairing) and feeds the console's models over these JNI seams; the
|
||||
//! console's own asks — start a session, quit, copy text — come back as events.
|
||||
//!
|
||||
//! The seam in one breath: `nativeConsoleCreate` builds the console (a `jlong` handle),
|
||||
//! the surface-lifecycle calls hand it somewhere to draw, `nativeConsoleSet*` /
|
||||
//! `nativeConsoleLibrary*` push model snapshots (JSON, the model types' own serde shape),
|
||||
//! `nativeConsoleMenu`/`PadSample`/`Pointer`/`Key`/`Text` are input,
|
||||
//! `nativeConsoleNextEvent` is the blocking event poll (actions, haptic pulses, editing
|
||||
//! state, settings to persist), `nativeConsoleDrainCmds` the command bus.
|
||||
//!
|
||||
//! JSON everywhere a struct crosses: `HostRow`, `PairPhase`, `WakeStatus`, `LibraryGame`,
|
||||
//! `LibraryPhase`, `ConsoleCmd`, `OverlayAction`, `Settings`, `KnownHosts` all serialize with
|
||||
//! serde in their defining crates — there is no second Android-side mirror to drift.
|
||||
|
||||
mod egl;
|
||||
mod gpu;
|
||||
mod host;
|
||||
|
||||
use host::{Cmd, ConsoleHost, Phase};
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JByteArray, JObject, JString};
|
||||
use jni::sys::{jboolean, jfloat, jint, jlong};
|
||||
use jni::EnvUnowned;
|
||||
use pf_client_core::console::{PointerButton, PointerInput};
|
||||
use pf_client_core::menu_nav::{MenuDir, MenuEvent, MenuSample, PadBattery, PadInfo};
|
||||
use pf_console_ui::{
|
||||
ConsoleEntry, ConsoleOptions, HostRow, Insets, Key, LibraryGame, LibraryPhase, PairPhase,
|
||||
Platform, SnapshotStore, Stale, WakeStatus,
|
||||
};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long `nativeConsoleNextEvent` blocks at most — short enough that Kotlin's poll thread
|
||||
/// notices `running = false` promptly on teardown (the rumble poll's cadence).
|
||||
const EVENT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// What Kotlin hands `nativeConsoleCreate`.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateOptions {
|
||||
device_name: String,
|
||||
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
|
||||
gpu_cache_bytes: usize,
|
||||
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
|
||||
/// gates the console-off settings row. Default false: absent means don't offer it.
|
||||
#[serde(default)]
|
||||
fallback_ui: bool,
|
||||
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
|
||||
settings: pf_client_core::trust::Settings,
|
||||
/// The profile catalog as `[[id, name], …]`.
|
||||
#[serde(default)]
|
||||
profiles: Vec<(String, String)>,
|
||||
/// The known-hosts records (`KnownHosts` JSON) — for building `punktfunk://` links.
|
||||
#[serde(default)]
|
||||
known_hosts: pf_client_core::trust::KnownHosts,
|
||||
/// Where to start: `{"home": true}` or `{"library": <HostRow>}`.
|
||||
#[serde(default)]
|
||||
entry: EntryJson,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct EntryJson {
|
||||
#[serde(default)]
|
||||
library: Option<HostRow>,
|
||||
}
|
||||
|
||||
impl EntryJson {
|
||||
fn into_entry(self) -> ConsoleEntry {
|
||||
match self.library {
|
||||
Some(h) => ConsoleEntry::Library(Box::new(h)),
|
||||
None => ConsoleEntry::Home,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One controller as Kotlin describes it — `PadInfo` with the pref as its wire byte.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PadJson {
|
||||
name: String,
|
||||
key: String,
|
||||
pref: u8,
|
||||
#[serde(default)]
|
||||
steam_virtual: bool,
|
||||
#[serde(default)]
|
||||
battery: Option<BatteryJson>,
|
||||
/// `VID:PID · gamepad · dpad` — what the controllers screen prints under the name.
|
||||
#[serde(default)]
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
forwarded: bool,
|
||||
#[serde(default)]
|
||||
rumble: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatteryJson {
|
||||
percent: u8,
|
||||
charging: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PadsJson {
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
/// The glyph style's pref as its wire byte; absent = keyboard glyphs.
|
||||
#[serde(default)]
|
||||
pref: Option<u8>,
|
||||
#[serde(default)]
|
||||
pads: Vec<PadJson>,
|
||||
}
|
||||
|
||||
/// The `jlong` handle → the host. Every entry point takes the handle Kotlin got from
|
||||
/// `nativeConsoleCreate` and returns it to `nativeConsoleDestroy` exactly once, never
|
||||
/// concurrently with the destroy (Kotlin owns that ordering on its main thread; the event
|
||||
/// poll thread is stopped and joined before destroy — same contract as the rumble poll).
|
||||
fn host(handle: jlong) -> Option<&'static ConsoleHost> {
|
||||
if handle == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: live handle per the create/destroy contract above.
|
||||
Some(unsafe { &*(handle as *const ConsoleHost) })
|
||||
}
|
||||
|
||||
fn json_arg<T: serde::de::DeserializeOwned>(env: &mut jni::Env, s: &JString) -> Option<T> {
|
||||
let text = s.try_to_string(env).ok()?;
|
||||
match serde_json::from_str::<T>(&text) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
log::error!("console: bad JSON from Kotlin: {e} in {text:.200}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleCreate(optionsJson): Long` — build the console (shell + fonts on
|
||||
/// the caller's thread, then its render thread parked until a surface arrives). `0` on failure
|
||||
/// (logged); Kotlin then keeps its own console.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCreate(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
options: JString,
|
||||
) -> jlong {
|
||||
env.with_env(|env| -> jni::errors::Result<jlong> {
|
||||
let Some(opts) = json_arg::<CreateOptions>(env, &options) else {
|
||||
return Ok(0);
|
||||
};
|
||||
let store = Arc::new(SnapshotStore::new(opts.settings, opts.profiles));
|
||||
store.set_known_hosts(opts.known_hosts);
|
||||
let console_opts = ConsoleOptions {
|
||||
device_name: opts.device_name,
|
||||
deck: false,
|
||||
fallback_ui: opts.fallback_ui,
|
||||
store: Some(store.clone()),
|
||||
platform: Platform::Android,
|
||||
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
|
||||
};
|
||||
let host = ConsoleHost::start(console_opts, opts.entry.into_entry(), store);
|
||||
Ok(Box::into_raw(Box::new(host)) as jlong)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleDestroy(handle)` — stop the render thread (joined) and free.
|
||||
///
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle, destroyed once, after the event poll thread stopped.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleDestroy(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the contract; ownership returns here exactly once.
|
||||
let host = unsafe { Box::from_raw(handle as *mut ConsoleHost) };
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSurfaceCreated(handle, surface)` — the `SurfaceView`'s surface is
|
||||
/// up; the render thread wraps it in EGL and starts drawing.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSurfaceCreated(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
surface: JObject,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
let Some(h) = host(handle) else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: `env`/`surface` are valid JNI pointers for this call; the raw casts bridge the
|
||||
// jni-sys version skew between the `jni` and vendored `ndk` crates (see nativeStartVideo).
|
||||
let window = unsafe {
|
||||
ndk::native_window::NativeWindow::from_surface(
|
||||
env.get_raw() as *mut _,
|
||||
surface.as_raw() as *mut _,
|
||||
)
|
||||
};
|
||||
match window {
|
||||
Some(w) => h.shared.send(Cmd::SurfaceCreated(w)),
|
||||
None => log::error!("console: no ANativeWindow from Surface"),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSurfaceChanged(handle)` — size changed; the thread re-reads it.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSurfaceChanged(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if let Some(h) = host(handle) {
|
||||
h.shared.send(Cmd::SurfaceChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSurfaceDestroyed(handle)` — BLOCKS until the render thread has
|
||||
/// released the EGL surface: Android forbids touching a `Surface` after `surfaceDestroyed`
|
||||
/// returns, and the GL driver would otherwise still be presenting into it.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSurfaceDestroyed(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if let Some(h) = host(handle) {
|
||||
h.shared.destroy_surface_blocking();
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)` — safe-area
|
||||
/// insets in surface pixels and the design-unit scale (`0` = the shell's own couch formula).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetViewport(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
left: jfloat,
|
||||
top: jfloat,
|
||||
right: jfloat,
|
||||
bottom: jfloat,
|
||||
scale: jfloat,
|
||||
) {
|
||||
if let Some(h) = host(handle) {
|
||||
h.shared.send(Cmd::Viewport {
|
||||
insets: Insets {
|
||||
left: left.max(0.0),
|
||||
top: top.max(0.0),
|
||||
right: right.max(0.0),
|
||||
bottom: bottom.max(0.0),
|
||||
},
|
||||
scale: (scale > 0.0).then_some(f64::from(scale)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsolePadSample(handle, buttons, lx, ly, dpad)` — the raw pad, whenever
|
||||
/// it changes: `buttons` bit i = a, b, x, y, l1, r1 held; `lx`/`ly` the left stick in wire
|
||||
/// units (±32767, +y = down); `dpad` bit i = up, down, left, right held. The shared
|
||||
/// `MenuNav` turns it into menu events with the same dead zone, repeat cadence and hysteresis
|
||||
/// as the desktop.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePadSample(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
buttons: jint,
|
||||
lx: jint,
|
||||
ly: jint,
|
||||
dpad: jint,
|
||||
) {
|
||||
if let Some(h) = host(handle) {
|
||||
let bit = |v: jint, i: u32| v & (1 << i) != 0;
|
||||
h.shared.send(Cmd::PadSample(MenuSample {
|
||||
buttons: [
|
||||
bit(buttons, 0),
|
||||
bit(buttons, 1),
|
||||
bit(buttons, 2),
|
||||
bit(buttons, 3),
|
||||
bit(buttons, 4),
|
||||
bit(buttons, 5),
|
||||
],
|
||||
lx: lx.clamp(-32767, 32767) as i16,
|
||||
ly: ly.clamp(-32767, 32767) as i16,
|
||||
dpad: [bit(dpad, 0), bit(dpad, 1), bit(dpad, 2), bit(dpad, 3)],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleMenu(handle, event)` — a discrete menu event, for input that is
|
||||
/// already an event on the Kotlin side (a TV remote's D-pad `KeyEvent`s, the touch escape hatch):
|
||||
/// 0..3 = move up/down/left/right, 4 confirm, 5 back, 6 secondary (Y), 7 tertiary (X),
|
||||
/// 8 jump back (L1), 9 jump forward (R1).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleMenu(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
event: jint,
|
||||
) {
|
||||
let ev = match event {
|
||||
0 => MenuEvent::Move(MenuDir::Up),
|
||||
1 => MenuEvent::Move(MenuDir::Down),
|
||||
2 => MenuEvent::Move(MenuDir::Left),
|
||||
3 => MenuEvent::Move(MenuDir::Right),
|
||||
4 => MenuEvent::Confirm,
|
||||
5 => MenuEvent::Back,
|
||||
6 => MenuEvent::Secondary,
|
||||
7 => MenuEvent::Tertiary,
|
||||
8 => MenuEvent::JumpBack,
|
||||
9 => MenuEvent::JumpForward,
|
||||
_ => return,
|
||||
};
|
||||
if let Some(h) = host(handle) {
|
||||
h.shared.send(Cmd::Menu(ev));
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsolePointer(handle, kind, x, y, dy)` — touch/mouse in surface pixels:
|
||||
/// kind 0 move, 1 primary down (a mouse — acts immediately), 2 primary up, 3 secondary down
|
||||
/// (= Back), 4 wheel (`dy` steps, + = up), 5 cancel, 6 primary down from a finger/stylus on
|
||||
/// the glass — the shell defers it so a swipe scrolls instead of acting on contact.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePointer(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
kind: jint,
|
||||
x: jfloat,
|
||||
y: jfloat,
|
||||
dy: jfloat,
|
||||
) {
|
||||
let input = match kind {
|
||||
0 => PointerInput::Move { x, y },
|
||||
1 => PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
touch: false,
|
||||
},
|
||||
2 => PointerInput::Up {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
},
|
||||
3 => PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Secondary,
|
||||
touch: false,
|
||||
},
|
||||
4 => PointerInput::Wheel { x, y, dy },
|
||||
5 => PointerInput::Cancel,
|
||||
6 => PointerInput::Down {
|
||||
x,
|
||||
y,
|
||||
button: PointerButton::Primary,
|
||||
touch: true,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
if let Some(h) = host(handle) {
|
||||
h.shared.send(Cmd::Pointer(input));
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleKey(handle, key, shift, repeat)` — a hardware key the console
|
||||
/// understands: 0..3 left/right/up/down, 4 return, 5 space, 6 escape, 7 backspace, 8 page up,
|
||||
/// 9 page down, 10 tab, 11 the letter Y, 12 the letter X. Anything else is Kotlin's to keep.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleKey(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
key: jint,
|
||||
shift: jboolean,
|
||||
repeat: jboolean,
|
||||
) {
|
||||
let key = match key {
|
||||
0 => Key::Left,
|
||||
1 => Key::Right,
|
||||
2 => Key::Up,
|
||||
3 => Key::Down,
|
||||
4 => Key::Return,
|
||||
5 => Key::Space,
|
||||
6 => Key::Escape,
|
||||
7 => Key::Backspace,
|
||||
8 => Key::PageUp,
|
||||
9 => Key::PageDown,
|
||||
10 => Key::Tab,
|
||||
11 => Key::Y,
|
||||
12 => Key::X,
|
||||
_ => return,
|
||||
};
|
||||
if let Some(h) = host(handle) {
|
||||
h.shared.send(Cmd::Key { key, shift, repeat });
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleText(handle, text)` — typed characters while the console reports
|
||||
/// `editing` (see the `editing` event).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleText(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
text: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Ok(t)) = (host(handle), text.try_to_string(env)) {
|
||||
h.shared.send(Cmd::Text(t));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSessionPhase(handle, phase, message)` — where the session the
|
||||
/// console asked for stands: 0 connecting, 1 streaming, 2 failed(message), 3 ended(message or
|
||||
/// empty = clean), 4 reconnecting(message).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSessionPhase(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
phase: jint,
|
||||
message: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
let Some(h) = host(handle) else {
|
||||
return Ok(());
|
||||
};
|
||||
let msg = message.try_to_string(env).unwrap_or_default();
|
||||
let ph = match phase {
|
||||
0 => Phase::Connecting,
|
||||
1 => Phase::Streaming,
|
||||
2 => Phase::Failed(msg),
|
||||
3 => Phase::Ended((!msg.is_empty()).then_some(msg)),
|
||||
4 => Phase::Reconnecting(msg),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
h.shared.send(Cmd::Phase(ph));
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleNavigate(handle, entryJson)` — re-root the console (`{"library":
|
||||
/// <HostRow>}` opens that host's shelf over Home; `{}` is Home).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNavigate(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
entry: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(e)) = (host(handle), json_arg::<EntryJson>(env, &entry)) {
|
||||
h.shared.send(Cmd::Navigate(e.into_entry()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetPads(handle, padsJson)` — the connected controllers for the
|
||||
/// chip, the settings rows and the controllers screen: `{"label": "DualSense", "pref": 1,
|
||||
/// "pads": [{name, key, pref, steam_virtual, battery: {percent, charging} | null, detail,
|
||||
/// forwarded, rumble}]}`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPads(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pads: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
let (Some(h), Some(p)) = (host(handle), json_arg::<PadsJson>(env, &pads)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let pads = p
|
||||
.pads
|
||||
.into_iter()
|
||||
.map(|j| PadInfo {
|
||||
name: j.name,
|
||||
key: j.key,
|
||||
pref: GamepadPref::from_u8(j.pref),
|
||||
steam_virtual: j.steam_virtual,
|
||||
battery: j.battery.map(|b| PadBattery {
|
||||
percent: b.percent.min(100),
|
||||
charging: b.charging,
|
||||
}),
|
||||
detail: j.detail,
|
||||
forwarded: j.forwarded,
|
||||
rumble: j.rumble,
|
||||
})
|
||||
.collect();
|
||||
h.shared.send(Cmd::Pads {
|
||||
label: p.label,
|
||||
pref: p.pref.map(GamepadPref::from_u8),
|
||||
pads,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleNextEvent(handle): String` — block up to ~100 ms for the next
|
||||
/// event: `{"action": <OverlayAction>}`, `{"pulse": "move"|"confirm"|"boundary"}`,
|
||||
/// `{"editing": bool}`, `{"settings": <Settings>}` (persist it), `{"gles": 2|3}`,
|
||||
/// `{"dead": "<why>"}`. Empty string on timeout / no handle. Run from a Kotlin poll thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNextEvent<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
let out = match host(handle).and_then(|h| h.shared.next_event(EVENT_TIMEOUT)) {
|
||||
Some(ev) => ev.to_json(),
|
||||
None => String::new(),
|
||||
};
|
||||
env.new_string(out)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleDrainCmds(handle): String` — every `ConsoleCmd` queued since the
|
||||
/// last call, as a JSON array (`[]` when none). Poll on a short cadence from the service side.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleDrainCmds<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
|
||||
let out = match host(handle) {
|
||||
Some(h) => {
|
||||
let cmds = h.handles.bus.drain();
|
||||
serde_json::to_string(&cmds).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
None => "[]".into(),
|
||||
};
|
||||
env.new_string(out)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
// ---- model pushers -----------------------------------------------------------------------
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetHosts(handle, json)` — the home carousel's rows (`[HostRow]`).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetHosts(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(rows)) = (host(handle), json_arg::<Vec<HostRow>>(env, &json)) {
|
||||
h.handles.console.set_hosts(rows);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetPair(handle, json)` — the pairing ceremony's phase
|
||||
/// (`"Idle"`, `"Busy"`, `{"Failed": "why"}`, `{"Paired": {"key": "…"}}`).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPair(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(p)) = (host(handle), json_arg::<PairPhase>(env, &json)) {
|
||||
h.handles.console.set_pair(p);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetWake(handle, json)` — the wake-and-wait card's status
|
||||
/// (`WakeStatus` JSON, or `null` to clear).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetWake(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(w)) = (host(handle), json_arg::<Option<WakeStatus>>(env, &json)) {
|
||||
h.handles.console.set_wake(w);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleNotice(handle, text)` — a one-shot toast from a service worker.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNotice(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
text: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Ok(t)) = (host(handle), text.try_to_string(env)) {
|
||||
h.handles.console.set_notice(t);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleLibraryBegin(handle)` — a fetch is starting for the shelf on
|
||||
/// screen: bumps the fetch epoch and sets `Loading`. Call this — not a bare `Loading` phase —
|
||||
/// so the shelf can tell its own result from a previous host's cached one.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryBegin(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if let Some(h) = host(handle) {
|
||||
h.handles.library.begin_fetch();
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleLibraryPhase(handle, json)` — `"Loading"`, `"Empty"`, `"Ready"`,
|
||||
/// or `{"Error": {"title", "body", "can_retry"}}`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryPhase(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(p)) = (host(handle), json_arg::<LibraryPhase>(env, &json)) {
|
||||
h.handles.library.set_phase(p);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleLibraryGames(handle, json, cached)` — the catalog (`[LibraryGame]`);
|
||||
/// `cached` = this is the last-known list from the cache, shown while the fetch runs.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryGames(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
cached: jboolean,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(games)) = (host(handle), json_arg::<Vec<LibraryGame>>(env, &json)) {
|
||||
if cached {
|
||||
h.handles.library.set_games_cached(games);
|
||||
} else {
|
||||
h.handles.library.set_games(games);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleLibraryArt(handle, id, bytes)` — one title's poster, encoded
|
||||
/// (JPEG/PNG); the shell decodes at the size it draws.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryArt(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: JString,
|
||||
bytes: JByteArray,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
let Some(h) = host(handle) else {
|
||||
return Ok(());
|
||||
};
|
||||
let id = id.try_to_string(env)?;
|
||||
let bytes = env.convert_byte_array(&bytes)?;
|
||||
h.handles.library.push_art(id, bytes);
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleLibraryRunning(handle, json)` — the ids the host has up
|
||||
/// (`["steam:570", …]`).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryRunning(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(ids)) = (host(handle), json_arg::<Vec<String>>(env, &json)) {
|
||||
let up: std::collections::HashSet<String> = ids.into_iter().collect();
|
||||
h.handles.library.set_running(&up);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleLibraryStale(handle, stale)` — 0 fresh, 1 waking, 2 offline.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryStale(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
stale: jint,
|
||||
) {
|
||||
if let Some(h) = host(handle) {
|
||||
h.handles.library.set_stale(match stale {
|
||||
1 => Stale::Waking,
|
||||
2 => Stale::Offline,
|
||||
_ => Stale::No,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetSettings(handle, json)` — a settings change made elsewhere
|
||||
/// (the touch UI, a deep link): the shell reads this on its next mutation. Not a save.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetSettings(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(s)) = (
|
||||
host(handle),
|
||||
json_arg::<pf_client_core::trust::Settings>(env, &json),
|
||||
) {
|
||||
h.store.set(s);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetProfiles(handle, json)` — the profile catalog `[[id, name]]`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetProfiles(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(p)) = (host(handle), json_arg::<Vec<(String, String)>>(env, &json)) {
|
||||
h.store.set_profiles(p);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetKnownHosts(handle, json)` — the known-hosts records
|
||||
/// (`KnownHosts` JSON) the console builds `punktfunk://` links from.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetKnownHosts(
|
||||
mut env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
json: JString,
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
if let (Some(h), Some(k)) = (
|
||||
host(handle),
|
||||
json_arg::<pf_client_core::trust::KnownHosts>(env, &json),
|
||||
) {
|
||||
h.store.set_known_hosts(k);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
//! The ASurfaceControl present backend (default): MediaCodec → `AImageReader` → `ASurfaceControl`
|
||||
//! transactions, scheduled against the panel's real present clock.
|
||||
//!
|
||||
//! Where the SurfaceView presenter ([`super::presenter`]) predicts SurfaceFlinger's latch off a
|
||||
//! choreographer grid that Android down-rates for a game uid, this backend gets the truth: every
|
||||
//! applied transaction reports its real latch time and the previous buffer's release fence on
|
||||
//! completion ([`super::surface_control::PresentComplete`]). Those two facts are the whole point —
|
||||
//! the panel period is learned from real latch spacings (no down-rate lie), the glass budget is
|
||||
//! bounded by real completions (no mispredicted reopen backpressuring the codec), and the latch
|
||||
//! metric is always available (not the best-effort `OnFrameRendered` the SurfaceView path leans on).
|
||||
//!
|
||||
//! Both present intents ride the one actuator — a desired present time on the transaction:
|
||||
//! * **latency** (default `present_priority`): newest-wins. Each pump drains the reader to the
|
||||
//! newest image (`acquireLatestImageAsync` drops the rest back to the pool) and presents it at
|
||||
//! the next real vsync. Minimal depth.
|
||||
//! * **smooth**: a small FIFO drained on each frame's [`CadenceClock`] due time — the source's own
|
||||
//! cadence, recovered from the wire pts, finally with a truthful present clock beneath it.
|
||||
//!
|
||||
//! Memory safety does NOT rest on the release fences: an `AImage` (and the `AHardwareBuffer` it
|
||||
//! wraps) stays alive through SurfaceFlinger's own reference taken by `setBuffer`, so deleting our
|
||||
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
|
||||
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
|
||||
//! auto-fallback with the residual risk being visual, not a crash.
|
||||
|
||||
use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::phase::{CadenceClock, CadenceTuning, PanelGrid};
|
||||
use std::collections::VecDeque;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
use super::latency::now_realtime_ns;
|
||||
use super::presenter::PresentPriority;
|
||||
use super::surface_control::{Layer, PresentComplete};
|
||||
use super::vsync::now_monotonic_ns;
|
||||
|
||||
/// Reader pool depth. Must cover the codec's own in-flight outputs + the presenter's held candidate
|
||||
/// / FIFO + the buffers still latched on SurfaceFlinger awaiting their release fence. Eight is
|
||||
/// generous for a one-in-flight-ish presenter and small enough that no device balks.
|
||||
const READER_MAX_IMAGES: i32 = 8;
|
||||
|
||||
/// SurfaceFlinger latch lead: a present targeted closer than this to a vsync is treated as missed
|
||||
/// and the next grid point is used. Starts at 0 (the P2e on-glass finding — SF latched with no lead
|
||||
/// on the NP3) and only ever grows if a device proves it needs more; kept simple here (fixed 0)
|
||||
/// because the real-latch feedback makes the aggressive gamble self-correcting: a miss just presents
|
||||
/// one vsync later, the same cost the predicted path always paid.
|
||||
const LATCH_MARGIN_NS: i64 = 0;
|
||||
|
||||
/// Fallback panel period while none has been learned yet — one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
/// One image acquired from the reader, held until it is presented (or dropped as a newest-wins
|
||||
/// eviction). Carries the decode stamps paired by pts for the latency metrics.
|
||||
struct Acquired {
|
||||
image: Image,
|
||||
buffer: HardwareBuffer,
|
||||
fence: Option<OwnedFd>,
|
||||
pts_us: u64,
|
||||
/// `CLOCK_REALTIME` decode-output stamp (for the skew-corrected end-to-end).
|
||||
decoded_real: i128,
|
||||
/// The source's due time on the cadence grid (`CLOCK_MONOTONIC`), `None` under latency.
|
||||
due_ns: Option<i64>,
|
||||
}
|
||||
|
||||
/// One image applied to SurfaceFlinger, awaiting its completion (metrics) and its successor's
|
||||
/// completion (the release fence that frees it back to the pool).
|
||||
struct Presented {
|
||||
seq: u64,
|
||||
image: Image,
|
||||
pts_us: u64,
|
||||
decoded_real: i128,
|
||||
/// `CLOCK_REALTIME` / `CLOCK_MONOTONIC` instants the transaction was applied — the latch metric
|
||||
/// pairs the completion's monotonic latch against `release_mono`, and rebases it onto realtime
|
||||
/// via `release_real` for the skew-corrected end-to-end.
|
||||
release_real: i128,
|
||||
release_mono: i64,
|
||||
}
|
||||
|
||||
/// The ASurfaceControl present backend.
|
||||
pub(super) struct AscBackend {
|
||||
reader: ImageReader,
|
||||
/// Cached reader window handed to `MediaCodec::configure` as the decoder's output surface.
|
||||
reader_window: NativeWindow,
|
||||
layer: Layer,
|
||||
/// `None` under latency; the source-cadence loop under smooth.
|
||||
cadence: Option<CadenceClock>,
|
||||
/// FIFO capacity: 0 = newest-wins (latency); 1..=3 = the smoothing store depth.
|
||||
fifo_capacity: usize,
|
||||
/// The negotiated source frame interval — the cadence cushion ceiling.
|
||||
frame_interval_ns: i64,
|
||||
/// Transactions applied but not yet completed — the real glass budget.
|
||||
inflight: u32,
|
||||
/// The pipeline depth the budget allows (2 = double-buffer; a shade more under smooth).
|
||||
inflight_cap: u32,
|
||||
|
||||
// -- held images --
|
||||
/// Latency: the newest acquired image not yet presented. Smooth leaves this `None`.
|
||||
candidate: Option<Acquired>,
|
||||
/// Smooth: images held for their due time, oldest first.
|
||||
fifo: VecDeque<Acquired>,
|
||||
/// Images on SurfaceFlinger, oldest first, awaiting release.
|
||||
presented: VecDeque<Presented>,
|
||||
|
||||
// -- present clock --
|
||||
/// The panel period learned from real latch spacings — a READOUT for the pf.present line only.
|
||||
/// It must NOT drive the present target: the target produces the latch, so learning the period
|
||||
/// from the latch and then targeting it locks the panel to whatever it first latched.
|
||||
panel: PanelGrid,
|
||||
/// The honest panel period from the mode table (`panel_hz`) — what the smooth grid snaps to.
|
||||
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
|
||||
panel_seed_ns: i64,
|
||||
last_latch_ns: i64,
|
||||
/// `ADataSpace` for the transaction (BT709 for SDR — never untagged; see `color_dataspace`).
|
||||
dataspace: i32,
|
||||
/// Layer frame-rate vote (source Hz), applied once.
|
||||
frame_rate: f32,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
|
||||
// -- bookkeeping --
|
||||
next_seq: u64,
|
||||
/// Decode stamps parked at `on_output`, keyed by the pts the codec echoes onto the buffer:
|
||||
/// `(pts_us, decoded_real_ns, decoded_mono_ns)`.
|
||||
stamps: VecDeque<(u64, i128, i64)>,
|
||||
|
||||
// -- 1 Hz pf.present window --
|
||||
released: u64,
|
||||
skipped: u64,
|
||||
displays: u64,
|
||||
forced: u64,
|
||||
latch_us: Vec<u64>,
|
||||
pace_us: Vec<u64>,
|
||||
e2e_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
}
|
||||
|
||||
impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
|
||||
/// `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
panel_hz: i32,
|
||||
dataspace: i32,
|
||||
source_hz: u32,
|
||||
priority: PresentPriority,
|
||||
) -> Option<AscBackend> {
|
||||
let layer = Layer::create(window, surface_size)?;
|
||||
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
|
||||
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
|
||||
let reader = match ImageReader::new_with_usage(
|
||||
src_w.max(1),
|
||||
src_h.max(1),
|
||||
ImageFormat::PRIVATE,
|
||||
usage,
|
||||
READER_MAX_IMAGES,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!("asc: ImageReader init failed ({e:?}) — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let reader_window = match reader.window() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("asc: ImageReader has no window ({e:?}) — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let frame_interval_ns = match source_hz {
|
||||
0 => FALLBACK_PERIOD_NS,
|
||||
hz => 1_000_000_000 / i64::from(hz),
|
||||
};
|
||||
let (fifo_capacity, cadence, inflight_cap) = match priority {
|
||||
PresentPriority::Latency => (0usize, None, 2u32),
|
||||
PresentPriority::Smooth { buffer } => (
|
||||
buffer,
|
||||
Some(CadenceClock::new(CadenceTuning::snapping())),
|
||||
(buffer as u32 + 1).clamp(2, 4),
|
||||
),
|
||||
};
|
||||
log::info!(
|
||||
"asc: backend up — {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
|
||||
match priority {
|
||||
PresentPriority::Latency => "latency (newest-wins)".to_string(),
|
||||
PresentPriority::Smooth { buffer } => format!("smooth (buffer {buffer})"),
|
||||
},
|
||||
src_w,
|
||||
src_h,
|
||||
source_hz,
|
||||
panel_hz,
|
||||
dataspace,
|
||||
);
|
||||
Some(AscBackend {
|
||||
reader,
|
||||
reader_window,
|
||||
layer,
|
||||
cadence,
|
||||
fifo_capacity,
|
||||
frame_interval_ns,
|
||||
inflight: 0,
|
||||
inflight_cap,
|
||||
candidate: None,
|
||||
fifo: VecDeque::new(),
|
||||
presented: VecDeque::new(),
|
||||
panel: PanelGrid::seeded(panel_hz),
|
||||
panel_seed_ns: if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
FALLBACK_PERIOD_NS
|
||||
},
|
||||
last_latch_ns: 0,
|
||||
dataspace,
|
||||
frame_rate: if source_hz > 0 { source_hz as f32 } else { 0.0 },
|
||||
src_w: src_w.max(1),
|
||||
src_h: src_h.max(1),
|
||||
next_seq: 0,
|
||||
stamps: VecDeque::new(),
|
||||
released: 0,
|
||||
skipped: 0,
|
||||
displays: 0,
|
||||
forced: 0,
|
||||
latch_us: Vec::with_capacity(256),
|
||||
pace_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The decoder output surface (the reader's window) for `MediaCodec::configure`.
|
||||
pub(super) fn reader_window(&self) -> &NativeWindow {
|
||||
&self.reader_window
|
||||
}
|
||||
|
||||
/// Re-anchor the cadence loop on the next frame — the discontinuity hook the decode loop calls
|
||||
/// when the re-anchor gate arms (a loss froze the picture and the decoder recovered behind it,
|
||||
/// so the source→presentable delay the loop measured no longer holds). No-op under latency.
|
||||
pub(super) fn reset_cadence(&mut self) {
|
||||
if let Some(c) = self.cadence.as_mut() {
|
||||
c.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one decoded output buffer: render it into the reader when `present` (the re-anchor
|
||||
/// gate approved it), else drop it off-glass. Parks the decode stamps for the pts the codec
|
||||
/// echoes onto the buffer so `pump` can pair the latency metrics after acquire.
|
||||
pub(super) fn on_output(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_real: i128,
|
||||
decoded_mono: i64,
|
||||
present: bool,
|
||||
) {
|
||||
if present {
|
||||
self.stamps.push_back((pts_us, decoded_real, decoded_mono));
|
||||
if self.stamps.len() > 128 {
|
||||
self.stamps.pop_front();
|
||||
}
|
||||
}
|
||||
if let Err(e) = codec.release_output_buffer_by_index(index, present) {
|
||||
log::warn!("asc: release_output_buffer_by_index({index}, {present}): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the decode stamps for `pts_us`, evicting older entries (decode order == input order).
|
||||
fn take_stamp(&mut self, pts_us: u64) -> Option<(i128, i64)> {
|
||||
while let Some(&(p, real, mono)) = self.stamps.front() {
|
||||
if p > pts_us {
|
||||
break;
|
||||
}
|
||||
self.stamps.pop_front();
|
||||
if p == pts_us {
|
||||
return Some((real, mono));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The desired present time for the frame being released, `CLOCK_MONOTONIC` (`0` = ASAP, only
|
||||
/// used to bootstrap the phase before the first latch is known).
|
||||
///
|
||||
/// Both modes snap `not_before` up to an explicit panel-grid point: without one, applying two
|
||||
/// transactions close together lets SurfaceFlinger coalesce the pair onto a single vsync and
|
||||
/// idle the next — the on-glass 60-on-a-120-panel result of a plain ASAP present. Giving each
|
||||
/// frame its own grid-spaced present time makes SF present them on consecutive vsyncs.
|
||||
///
|
||||
/// PERIOD is the mode-table seed (the honest panel maximum) — NEVER the latch-learned period,
|
||||
/// or a slow latch would ratchet the target down and hold the panel at the lower rate. PHASE is
|
||||
/// the last real latch. Latency passes `not_before = now + margin`; smooth additionally floors
|
||||
/// it at the source due time.
|
||||
fn next_present_target(&self, now_mono: i64, not_before: i64) -> i64 {
|
||||
let period = self.panel_seed_ns;
|
||||
if self.last_latch_ns <= 0 || period <= 0 {
|
||||
return 0; // bootstrap: no phase yet — present ASAP to establish the first latch
|
||||
}
|
||||
let floor = not_before.max(now_mono);
|
||||
let ahead = floor - self.last_latch_ns;
|
||||
let k = ahead.div_euclid(period) + 1;
|
||||
self.last_latch_ns + k.max(1) * period
|
||||
}
|
||||
|
||||
/// Drain the reader into the held set (newest-wins candidate, or the smoothing FIFO), then
|
||||
/// present the due frame if the budget is open. Returns `true` when a frame was applied.
|
||||
pub(super) fn pump(
|
||||
&mut self,
|
||||
now_mono: i64,
|
||||
stats: &crate::stats::VideoStats,
|
||||
ev_tx: &mpsc::Sender<DecodeEvent>,
|
||||
) -> bool {
|
||||
self.drain_reader();
|
||||
if self.inflight >= self.inflight_cap {
|
||||
return false;
|
||||
}
|
||||
// Pick the frame to present.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.candidate.take()
|
||||
} else {
|
||||
let reach = now_mono + LATCH_MARGIN_NS + self.panel_seed_ns;
|
||||
match self.fifo.front() {
|
||||
Some(f) if f.due_ns.is_none_or(|due| due <= reach) => self.fifo.pop_front(),
|
||||
_ => return false,
|
||||
}
|
||||
};
|
||||
let Some(mut frame) = frame else {
|
||||
return false;
|
||||
};
|
||||
let not_before = frame.due_ns.map_or(now_mono + LATCH_MARGIN_NS, |d| {
|
||||
d.max(now_mono + LATCH_MARGIN_NS)
|
||||
});
|
||||
let target = self.next_present_target(now_mono, not_before);
|
||||
let seq = self.next_seq;
|
||||
let applied = self.layer.present(
|
||||
&frame.buffer,
|
||||
self.src_w,
|
||||
self.src_h,
|
||||
frame.fence.take(),
|
||||
target,
|
||||
self.dataspace,
|
||||
// The layer's fixed-source rate — applied once, at layer config (see `Layer::present`).
|
||||
self.frame_rate,
|
||||
seq,
|
||||
ev_tx,
|
||||
);
|
||||
if !applied {
|
||||
return false; // transaction failed; the image drops here, back to the pool
|
||||
}
|
||||
let release_real = now_realtime_ns();
|
||||
let pace_us = ((release_real - frame.decoded_real).max(0) / 1000) as u64;
|
||||
self.pace_us.push(pace_us);
|
||||
stats.note_release(pace_us);
|
||||
self.presented.push_back(Presented {
|
||||
seq,
|
||||
image: frame.image,
|
||||
pts_us: frame.pts_us,
|
||||
decoded_real: frame.decoded_real,
|
||||
release_real,
|
||||
release_mono: now_mono,
|
||||
});
|
||||
self.inflight += 1;
|
||||
self.next_seq += 1;
|
||||
self.released += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
|
||||
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
|
||||
fn drain_reader(&mut self) {
|
||||
if self.fifo_capacity == 0 {
|
||||
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
|
||||
if let Some(acq) = self.acquire(true) {
|
||||
if self.candidate.replace(acq).is_some() {
|
||||
self.skipped += 1; // an un-presented candidate was superseded
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
|
||||
while let Some(acq) = self.acquire(false) {
|
||||
self.fifo.push_back(acq);
|
||||
while self.fifo.len() > self.fifo_capacity {
|
||||
self.fifo.pop_front();
|
||||
self.skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
|
||||
/// `None` when the reader is empty or a transient acquire error occurs.
|
||||
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
|
||||
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
|
||||
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
|
||||
// acquire requires.
|
||||
let res = unsafe {
|
||||
if latest {
|
||||
self.reader.acquire_latest_image_async()
|
||||
} else {
|
||||
self.reader.acquire_next_image_async()
|
||||
}
|
||||
};
|
||||
let (image, fence) = match res {
|
||||
Ok(AcquireResult::Image(pair)) => pair,
|
||||
Ok(_) => return None, // no buffer available / max acquired
|
||||
Err(e) => {
|
||||
log::warn!("asc: acquire image failed: {e:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let buffer = match image.hardware_buffer() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("asc: image has no hardware buffer: {e:?}");
|
||||
return None; // `image` drops here → back to the pool
|
||||
}
|
||||
};
|
||||
// The buffer timestamp is the pts the codec echoed (ns); pair the parked decode stamps.
|
||||
let pts_ns = image.timestamp().unwrap_or(0).max(0);
|
||||
let pts_us = (pts_ns / 1000) as u64;
|
||||
let (decoded_real, decoded_mono) = self
|
||||
.take_stamp(pts_us)
|
||||
.unwrap_or((now_realtime_ns(), now_monotonic_ns()));
|
||||
let due_ns = self.cadence.as_mut().map(|c| {
|
||||
c.due_ns(
|
||||
pts_us.saturating_mul(1000),
|
||||
decoded_mono,
|
||||
self.frame_interval_ns,
|
||||
)
|
||||
});
|
||||
Some(Acquired {
|
||||
image,
|
||||
buffer,
|
||||
fence,
|
||||
pts_us,
|
||||
decoded_real,
|
||||
due_ns,
|
||||
})
|
||||
}
|
||||
|
||||
/// A completed transaction: reopen the budget, learn the panel period from the real latch,
|
||||
/// record the latch + end-to-end, and free the buffer this frame replaced with its release
|
||||
/// fence. Runs on the decode thread (the callback only forwarded the data).
|
||||
pub(super) fn on_present_complete(
|
||||
&mut self,
|
||||
pc: PresentComplete,
|
||||
clock_offset: i64,
|
||||
stats: &crate::stats::VideoStats,
|
||||
video_e2e: &AtomicU64,
|
||||
) {
|
||||
self.inflight = self.inflight.saturating_sub(1);
|
||||
// Metrics for the frame that just latched (its own `seq`).
|
||||
if pc.latch_ns > 0 {
|
||||
if let Some(p) = self.presented.iter().find(|p| p.seq == pc.seq) {
|
||||
let latch_ns = (pc.latch_ns - p.release_mono).clamp(0, 10_000_000_000);
|
||||
let displayed_real = p.release_real + latch_ns as i128;
|
||||
let e2e_ns = displayed_real + clock_offset as i128 - p.pts_us as i128 * 1000;
|
||||
let latch_use = (latch_ns / 1000) as u64;
|
||||
let display_use = ((displayed_real - p.decoded_real).max(0) / 1000) as u64;
|
||||
self.latch_us.push(latch_use);
|
||||
self.displays += 1;
|
||||
if e2e_ns > 0 && e2e_ns < 10_000_000_000 {
|
||||
let e2e_use = (e2e_ns / 1000) as u64;
|
||||
self.e2e_us.push(e2e_use);
|
||||
// Publish glass-to-glass RAW for the audio plane to align against.
|
||||
video_e2e.store(e2e_ns as u64, Ordering::Relaxed);
|
||||
stats.note_displayed(Some(e2e_use), Some(display_use), Some(latch_use));
|
||||
} else {
|
||||
stats.note_displayed(None, Some(display_use), Some(latch_use));
|
||||
}
|
||||
}
|
||||
// Learn the true panel period from consecutive real latches.
|
||||
if self.last_latch_ns > 0 {
|
||||
self.panel.observe(pc.latch_ns - self.last_latch_ns);
|
||||
}
|
||||
self.last_latch_ns = pc.latch_ns;
|
||||
}
|
||||
// Retire every buffer this transaction replaced (seq < completed): the immediate
|
||||
// predecessor gets the real release fence, any older straggler a plain delete (memory-safe
|
||||
// — SurfaceFlinger holds its own reference until it is actually done).
|
||||
let mut retired: Vec<Presented> = Vec::new();
|
||||
while self.presented.front().is_some_and(|p| p.seq < pc.seq) {
|
||||
retired.push(self.presented.pop_front().unwrap());
|
||||
}
|
||||
match (retired.pop(), pc.prev_release_fence) {
|
||||
(Some(last), Some(fence)) => last.image.delete_async(fence),
|
||||
(Some(last), None) => drop(last.image),
|
||||
(None, Some(fence)) => drop(fence),
|
||||
(None, None) => {}
|
||||
}
|
||||
// (`retired` now holds only older stragglers, dropped here — plain AImage_delete.)
|
||||
drop(retired);
|
||||
}
|
||||
|
||||
/// Publish the reader-drop count to the HUD and emit the 1 Hz `pf.present` mirror line. Called
|
||||
/// once per loop pass; the `skipped` counter feeds the HUD each pass, the log line at 1 Hz.
|
||||
pub(super) fn flush(&mut self, stats: &crate::stats::VideoStats) {
|
||||
if self.skipped > 0 {
|
||||
stats.note_skipped(std::mem::take(&mut self.skipped));
|
||||
}
|
||||
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
|
||||
return;
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
if self.released == 0 && self.displays == 0 {
|
||||
return; // idle
|
||||
}
|
||||
let (latch_p50, latch_max) = p50_max_ms(std::mem::take(&mut self.latch_us));
|
||||
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||||
let (e2e_p50, e2e_max) = p50_max_ms(std::mem::take(&mut self.e2e_us));
|
||||
// Under the smoothness intent, tail the source-cadence loop's health: `late‰` of all frames
|
||||
// folded (a due time already past when the frame became presentable — the direct signal the
|
||||
// cushion is too small, WP8's acceptance criterion), `jitter` (the loop residual's mean
|
||||
// absolute deviation), `cushion`, and `reanchors`. Absent under latency (no loop). Counters
|
||||
// are cumulative since the last re-anchor, so `late` reads as a rate over enough frames.
|
||||
let cadence = self
|
||||
.cadence
|
||||
.as_ref()
|
||||
.map(CadenceClock::health)
|
||||
.map(|h| {
|
||||
format!(
|
||||
" late={}‰ jitterMs={:.2} cushionMs={:.2} reanchors={}",
|
||||
h.late.saturating_mul(1000) / h.frames.max(1),
|
||||
h.jitter_ns as f64 / 1e6,
|
||||
h.cushion_ns as f64 / 1e6,
|
||||
h.reanchors,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"asc released={} displays={} inflight={} qDepth={} paceMs p50={:.2} max={:.2} \
|
||||
latchMs p50={:.2} max={:.2} e2eMs p50={:.2} max={:.2} panelMs={:.2} forced={}{}",
|
||||
self.released,
|
||||
self.displays,
|
||||
self.inflight,
|
||||
self.fifo.len(),
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
latch_max,
|
||||
e2e_p50,
|
||||
e2e_max,
|
||||
self.panel.period_ns() as f64 / 1e6,
|
||||
self.forced,
|
||||
cadence,
|
||||
);
|
||||
self.released = 0;
|
||||
self.displays = 0;
|
||||
}
|
||||
|
||||
/// Teardown: drop every held image (candidate, FIFO, and still-presented) back to the pool
|
||||
/// before the reader + codec go away. Plain deletes — SurfaceFlinger releases its own refs as
|
||||
/// it finishes, so this is memory-safe without waiting on the fences.
|
||||
pub(super) fn release_all(&mut self) {
|
||||
self.candidate = None;
|
||||
self.fifo.clear();
|
||||
self.presented.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl AscBackend {
|
||||
/// Update the `ADataSpace` applied to every subsequent transaction (a refinement from the
|
||||
/// codec's output format — the analogue of the SurfaceView path's `apply_hdr_dataspace`; the
|
||||
/// negotiated colour set the initial value at create).
|
||||
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
|
||||
if self.dataspace != dataspace {
|
||||
self.dataspace = dataspace;
|
||||
log::info!("asc: buffer dataspace now {dataspace:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the ASurfaceControl backend is selected. Default ON; `debug.punktfunk.present_backend =
|
||||
/// surfaceview` forces the legacy SurfaceView presenter (the field escape hatch, no rebuild). Any
|
||||
/// other value — or an ASC init failure downstream — still lands on ASC-then-fallback.
|
||||
pub(super) fn asc_backend_selected() -> bool {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.present_backend".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
!(n > 0 && &buf[..n as usize] == b"surfaceview")
|
||||
}
|
||||
|
||||
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
|
||||
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||||
if v.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
v.sort_unstable();
|
||||
(
|
||||
v[v.len() / 2] as f64 / 1000.0,
|
||||
*v.last().unwrap() as f64 / 1000.0,
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,10 @@ use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::asc_presenter::{asc_backend_selected, AscBackend};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||
apply_hdr_dataspace, color_dataspace, hdr_dataspace, install_render_callback,
|
||||
release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
@@ -22,6 +24,7 @@ use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::surface_control::PresentComplete;
|
||||
use super::vsync::{now_monotonic_ns, VsyncClock};
|
||||
use super::{
|
||||
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
||||
@@ -43,7 +46,7 @@ struct OutputReady {
|
||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
pub(super) enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
|
||||
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
|
||||
@@ -63,6 +66,10 @@ enum DecodeEvent {
|
||||
FormatChanged,
|
||||
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
|
||||
Vsync,
|
||||
/// An `ASurfaceControl` transaction completed (ASurfaceControl backend only): the real latch
|
||||
/// time + the previous buffer's release fence, forwarded from the completion callback (a binder
|
||||
/// thread) so the decode loop applies it on its own thread.
|
||||
PresentComplete(super::surface_control::PresentComplete),
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
Error { fatal: bool },
|
||||
}
|
||||
@@ -89,6 +96,7 @@ pub(super) fn run_async(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
surface_size,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -176,7 +184,37 @@ pub(super) fn run_async(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
|
||||
// Resolve the present intent once (shared by both backends).
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output surface +
|
||||
// compositor layer, scheduling against the panel's real present clock; the SurfaceView presenter
|
||||
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
|
||||
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
|
||||
let mut asc = if asc_backend_selected() {
|
||||
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
|
||||
// output format later corrects; many decoders never echo `color-transfer` at all.
|
||||
let initial_ds = color_dataspace(&client.color);
|
||||
AscBackend::create(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
surface_size,
|
||||
panel_hz,
|
||||
initial_ds,
|
||||
mode.refresh_hz,
|
||||
priority,
|
||||
)
|
||||
} else {
|
||||
log::info!("decode: present backend = SurfaceView (present_backend sysprop)");
|
||||
None
|
||||
};
|
||||
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
|
||||
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
|
||||
if let Err(e) = codec.configure(
|
||||
&format,
|
||||
Some(configure_window),
|
||||
MediaCodecDirection::Decoder,
|
||||
) {
|
||||
log::error!("decode: configure failed: {e}");
|
||||
return;
|
||||
}
|
||||
@@ -190,8 +228,10 @@ pub(super) fn run_async(
|
||||
mode.height
|
||||
);
|
||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
||||
// off, every form factor gets the original soft seamless hint.
|
||||
if mode.refresh_hz > 0
|
||||
// off, every form factor gets the original soft seamless hint. ASC votes the rate on its own
|
||||
// layer instead (the SurfaceView window shows nothing under the ASC path).
|
||||
if asc.is_none()
|
||||
&& mode.refresh_hz > 0
|
||||
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
|
||||
{
|
||||
log::debug!(
|
||||
@@ -205,6 +245,11 @@ pub(super) fn run_async(
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// The shared cell the audio plane steers its jitter ring by — video is the master, and the
|
||||
// present path is the only point that knows when a frame actually reached glass. Both backends
|
||||
// publish into it (the ASC path from its transaction completions, the SurfaceView path from the
|
||||
// OnFrameRendered tracker).
|
||||
let video_e2e = client.video_e2e_shared();
|
||||
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
|
||||
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
@@ -212,27 +257,30 @@ pub(super) fn run_async(
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
// reclaimed after the codec is dropped below. SurfaceView backend only — the ASC path measures
|
||||
// its display stage directly off the transaction completions.
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
// The tracker also publishes each confirmed present's end-to-end into the shared cell the audio
|
||||
// plane steers its jitter ring by (`design/audio-latency-overhaul.md`) — video is the master,
|
||||
// and this is the only point that knows when a frame actually reached glass.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
video_e2e.clone(),
|
||||
meter.clone(),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
let render_cb = if asc.is_none() {
|
||||
install_render_callback(&codec, &tracker)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
|
||||
// legacy release-immediately path for a rebuild-free on-device A/B.
|
||||
let mut presenter = if presenter_disabled_by_sysprop() {
|
||||
// The SurfaceView timeline presenter (see `presenter.rs`): newest-wins / smoothing store,
|
||||
// one-in-flight glass budget, timeline-timed release. `None` under the ASC backend, or when
|
||||
// `debug.punktfunk.presenter = arrival` selects the legacy release-immediately path.
|
||||
let mut presenter = if asc.is_some() {
|
||||
None
|
||||
} else if presenter_disabled_by_sysprop() {
|
||||
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
|
||||
None
|
||||
} else {
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
log::info!(
|
||||
"decode: presenter = timeline ({})",
|
||||
match priority {
|
||||
@@ -242,11 +290,15 @@ pub(super) fn run_async(
|
||||
);
|
||||
Some(Presenter::new(priority, mode.refresh_hz))
|
||||
};
|
||||
stats.set_presenter_active(presenter.is_some());
|
||||
stats.set_presenter_active(presenter.is_some() || asc.is_some());
|
||||
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
|
||||
// the same event channel. The Sender parks here until that moment.
|
||||
// the same event channel. The ASC backend derives its present clock from the real transaction
|
||||
// latches instead, so it needs no choreographer.
|
||||
let mut vsync: Option<VsyncClock> = None;
|
||||
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
|
||||
// A persistent Sender for the ASC path: the pump hands it to each transaction's completion
|
||||
// callback, and it keeps the event channel alive for those callbacks.
|
||||
let present_tx = asc.as_ref().map(|_| ev_tx.clone());
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||
@@ -337,35 +389,52 @@ pub(super) fn run_async(
|
||||
let mut fmt_dirty = false;
|
||||
let mut vsync_tick = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
// ASurfaceControl transaction completions coalesced into this pass, applied after the
|
||||
// event drain (they run on the decode thread, not the binder thread that posted them).
|
||||
let mut present_completes: Vec<PresentComplete> = Vec::new();
|
||||
if let Some(ev) = ev0 {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
if let DecodeEvent::PresentComplete(pc) = ev {
|
||||
present_completes.push(pc);
|
||||
} else {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
// presentation across a decode burst, and batched feeding.
|
||||
while let Ok(ev) = ev_rx.try_recv() {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
if let DecodeEvent::PresentComplete(pc) = ev {
|
||||
present_completes.push(pc);
|
||||
} else {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(a) = asc.as_mut() {
|
||||
let off = clock_offset.load(Ordering::Relaxed);
|
||||
for pc in present_completes.drain(..) {
|
||||
a.on_present_complete(pc, off, &stats, &video_e2e);
|
||||
}
|
||||
}
|
||||
if vsync_tick {
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
@@ -374,7 +443,17 @@ pub(super) fn run_async(
|
||||
}
|
||||
stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
if let Some(a) = asc.as_mut() {
|
||||
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
|
||||
// Refine only when the codec actually reports an HDR transfer — a `None` echo
|
||||
// (decoders commonly omit `color-transfer`) must not clobber the negotiated
|
||||
// dataspace back to SDR before the first present.
|
||||
if let Some(ds) = hdr_dataspace(&codec) {
|
||||
a.set_dataspace(i32::from(ds));
|
||||
}
|
||||
} else {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
}
|
||||
feed_ready(
|
||||
&codec,
|
||||
@@ -399,26 +478,48 @@ pub(super) fn run_async(
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.reset_cadence();
|
||||
}
|
||||
if let Some(a) = asc.as_mut() {
|
||||
a.reset_cadence();
|
||||
}
|
||||
}
|
||||
let had_output = !ready.is_empty();
|
||||
let rendered_before = rendered;
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
&mut queued_stamps,
|
||||
&meter,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut presenter,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
if let Some(a) = asc.as_mut() {
|
||||
// ASC path: fold the gate + record the decode-stage split (same as the SurfaceView
|
||||
// path's measurement half), then render each approved output into the reader; the pump
|
||||
// below composites it onto the layer.
|
||||
asc_present_ready(
|
||||
a,
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
&mut queued_stamps,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
} else {
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
&mut queued_stamps,
|
||||
&meter,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut presenter,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
}
|
||||
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
|
||||
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
|
||||
// even when the choreographer clock is absent.
|
||||
@@ -475,6 +576,18 @@ pub(super) fn run_async(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The ASurfaceControl backend's decision point — same "runs every pass" contract as the
|
||||
// SurfaceView presenter, but its clock is the real transaction latches, so no choreographer
|
||||
// is consulted. `present_tx` is the persistent Sender each transaction's completion callback
|
||||
// rides back on.
|
||||
if let Some(a) = asc.as_mut() {
|
||||
if let Some(tx) = present_tx.as_ref() {
|
||||
if a.pump(now_monotonic_ns(), &stats, tx) {
|
||||
rendered += 1;
|
||||
}
|
||||
}
|
||||
a.flush(&stats);
|
||||
}
|
||||
let presented_now = rendered > rendered_before;
|
||||
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
|
||||
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
|
||||
@@ -584,6 +697,9 @@ pub(super) fn run_async(
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.release_all(&codec); // hand every held output buffer back before the codec stops
|
||||
}
|
||||
if let Some(a) = asc.as_mut() {
|
||||
a.release_all(); // drop every held image back to the reader pool before it goes away
|
||||
}
|
||||
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
|
||||
let _ = codec.stop();
|
||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||
@@ -591,6 +707,10 @@ pub(super) fn run_async(
|
||||
let _ = j.join();
|
||||
}
|
||||
drop(codec); // AMediaCodec_delete — after this no render callback can fire
|
||||
// The ASC layer + reader outlive the codec (which rendered into the reader's window); dropping
|
||||
// now releases the reader and decrements the compositor control's refcount — the control itself
|
||||
// is freed only once every in-flight completion callback has also dropped its share.
|
||||
drop(asc);
|
||||
if let Some(ud) = render_cb {
|
||||
// SAFETY: the codec was dropped above; this registration's single reclaim.
|
||||
unsafe { release_render_callback(ud) };
|
||||
@@ -777,6 +897,9 @@ fn dispatch_event(
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
}
|
||||
// Intercepted by the caller before it ever reaches here (routed to the ASC backend on the
|
||||
// decode thread); this arm keeps the match exhaustive.
|
||||
DecodeEvent::PresentComplete(_) => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1059,3 +1182,80 @@ fn present_ready(
|
||||
}
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||
}
|
||||
|
||||
/// The ASurfaceControl backend's analogue of [`present_ready`]: record the same decode-stage split
|
||||
/// (the HUD histogram + the ABR decoder-backlog signal), then fold each decoded output through the
|
||||
/// re-anchor gate and render it into the reader (`present = true`) or drop it off-glass. The pump
|
||||
/// composites the rendered images onto the layer; the display stage is measured there from the real
|
||||
/// transaction latches, not here. `ready` is drained.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors `present_ready`'s measurement half
|
||||
fn asc_present_ready(
|
||||
asc: &mut AscBackend,
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
queued_stamps: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) {
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Decode-stage measurement (identical to the SurfaceView path's first block, minus the
|
||||
// PresentMeter — the ASC backend keeps its own 1 Hz line). Pairs each output's receipt +
|
||||
// queued stamps for the `decode` histogram, the feed/codec split, and the ABR signal.
|
||||
{
|
||||
let want_stage = stats.enabled() || measure_decode;
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
let received_ns = if want_stage {
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let queued = take_stamp(queued_stamps, o.pts_us);
|
||||
let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64);
|
||||
if let Some(c) = codec_us {
|
||||
let feed_us = match (queued, received_ns) {
|
||||
(Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64),
|
||||
_ => None,
|
||||
};
|
||||
stats.note_decode_split(feed_us, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fold every output through the gate in pts (== decode) order — a `false` verdict is withheld
|
||||
// concealment (dropped off-glass, the ASC equivalent of the SurfaceView release-unrendered).
|
||||
let now = Instant::now();
|
||||
let mut withheld: u64 = 0;
|
||||
for o in ready.drain(..) {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
if !present {
|
||||
withheld += 1;
|
||||
}
|
||||
asc.on_output(
|
||||
codec,
|
||||
o.index,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
o.decoded_mono_ns,
|
||||
present,
|
||||
);
|
||||
}
|
||||
stats.note_skipped(withheld); // gate-withheld frames (the reader-drop skips ride `asc.flush`)
|
||||
}
|
||||
|
||||
@@ -274,3 +274,26 @@ pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the *negotiated* session colour ([`ColorInfo`], carried on Welcome) to the `ADataSpace`
|
||||
/// the presenter should tag buffers with. This is the authoritative source — the wire contract
|
||||
/// says clients configure the presenter from these code points, not from what the decoder happens
|
||||
/// to echo back (many decoders omit `color-transfer` from the output format).
|
||||
///
|
||||
/// SDR maps to `BT709` (limited-range video), never `0`/untagged: an untagged buffer on an
|
||||
/// ASurfaceControl transaction leaves SurfaceFlinger to guess, and a full-range guess shows
|
||||
/// limited-range black (16) as gray — the elevated-blacks bug.
|
||||
// ponytail: full-range SDR would need hand-composed dataspace bits (no named constant); the host
|
||||
// only encodes limited-range SDR today (ColorInfo::SDR_BT709), so BT709 covers every SDR session.
|
||||
pub(super) fn color_dataspace(color: &punktfunk_core::quic::ColorInfo) -> i32 {
|
||||
use punktfunk_core::quic::ColorInfo;
|
||||
let full = color.full_range != 0;
|
||||
let ds = match color.transfer {
|
||||
ColorInfo::TRC_PQ if full => DataSpace::Bt2020Pq,
|
||||
ColorInfo::TRC_PQ => DataSpace::Bt2020ItuPq,
|
||||
ColorInfo::TRC_HLG if full => DataSpace::Bt2020Hlg,
|
||||
ColorInfo::TRC_HLG => DataSpace::Bt2020ItuHlg,
|
||||
_ => DataSpace::Bt709, // SDR — limited-range BT.709 video
|
||||
};
|
||||
i32::from(ds)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
||||
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
||||
//! Android video decode (android-only): pull HEVC access units from the connector into NDK
|
||||
//! `AMediaCodec` — hardware decode, zero per-frame JNI.
|
||||
//!
|
||||
//! The decoded frames reach glass through one of two present backends (see [`asc_presenter`] and
|
||||
//! [`presenter`]). The default is the **ASurfaceControl** backend: the codec renders into an
|
||||
//! `AImageReader` and each frame is composited onto an `ASurfaceControl` layer via a transaction
|
||||
//! carrying a desired present time, scheduling against the panel's real present clock. The
|
||||
//! **SurfaceView** presenter — `releaseOutputBufferAtTime` straight to the SurfaceView's window — is
|
||||
//! the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview` sysprop.
|
||||
//!
|
||||
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
||||
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
||||
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
||||
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
||||
//! the codec + surface for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
||||
|
||||
mod asc_presenter;
|
||||
mod async_loop;
|
||||
mod display;
|
||||
mod latency;
|
||||
mod presenter;
|
||||
mod setup;
|
||||
mod surface_control;
|
||||
mod sync_loop;
|
||||
mod vsync;
|
||||
|
||||
@@ -82,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
///
|
||||
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
|
||||
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
|
||||
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
|
||||
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
|
||||
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
|
||||
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
@@ -124,6 +140,12 @@ pub(crate) struct DecodeOptions {
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
|
||||
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
|
||||
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
|
||||
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
|
||||
/// yet, and the backend falls back to the window buffer size.
|
||||
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The `ASurfaceControl` compositor layer behind the ASurfaceControl presenter backend.
|
||||
//!
|
||||
//! This is the Android analogue of what the Apple client gets from `CAMetalDisplayLink` +
|
||||
//! `preferredFrameLatency = 1`: a present path that schedules each frame against the panel's own
|
||||
//! timeline and hands back the *real* present feedback, instead of the MediaCodec→SurfaceView→
|
||||
//! BufferQueue path that predicts the latch and hopes the `OnFrameRendered` callbacks arrive.
|
||||
//!
|
||||
//! A `Layer` owns one `ASurfaceControl` created as a child of the SurfaceView's `ANativeWindow`;
|
||||
//! the decoder renders into an `AImageReader` and the
|
||||
//! presenter composites each acquired `AHardwareBuffer` onto this layer via an `ASurfaceTransaction`
|
||||
//! that carries a desired present time (the single actuator both present modes drive) and an
|
||||
//! acquire fence. Every applied transaction registers a one-shot completion callback that reports
|
||||
//! the frame's real latch time and the *previous* buffer's release fence back through the decode
|
||||
//! loop's event channel — the truthful present clock the cadence loop and the glass budget were
|
||||
//! missing.
|
||||
//!
|
||||
//! Every `ASurface*` entry point is **API 29** — above the crate's minSdk-28 floor — so all are
|
||||
//! `dlsym`-resolved from `libandroid.so`, exactly as [`crate::adpf`] and [`super::vsync`] resolve
|
||||
//! their own >-floor symbols; a hard import of any of them would make `System.loadLibrary` fail on
|
||||
//! every API-28 device even where this backend is never selected. Absent (or a null layer) ⇒
|
||||
//! [`Layer::create`] returns `None` and the caller falls back to the SurfaceView presenter.
|
||||
|
||||
use ndk::hardware_buffer::HardwareBuffer;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use super::async_loop::DecodeEvent;
|
||||
|
||||
// ---- Opaque native types (not in `ndk-sys 0.6`) ------------------------------------------------
|
||||
|
||||
#[repr(C)]
|
||||
struct ASurfaceControl {
|
||||
_p: [u8; 0],
|
||||
}
|
||||
#[repr(C)]
|
||||
struct ASurfaceTransaction {
|
||||
_p: [u8; 0],
|
||||
}
|
||||
#[repr(C)]
|
||||
struct ASurfaceTransactionStats {
|
||||
_p: [u8; 0],
|
||||
}
|
||||
|
||||
/// `ARect` — the `setGeometry` source/destination rectangle (`android/native_window.h`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ARect {
|
||||
left: i32,
|
||||
top: i32,
|
||||
right: i32,
|
||||
bottom: i32,
|
||||
}
|
||||
|
||||
/// `ANATIVEWINDOW_TRANSFORM_IDENTITY` — no rotation/flip; the decoder already emits upright frames.
|
||||
const TRANSFORM_IDENTITY: i32 = 0;
|
||||
/// `ASURFACE_TRANSACTION_VISIBILITY_SHOW`.
|
||||
const VISIBILITY_SHOW: i8 = 1;
|
||||
|
||||
// ---- The `dlsym`-resolved entry-point table ----------------------------------------------------
|
||||
|
||||
type CreateFromWindowFn = unsafe extern "C" fn(
|
||||
*mut ndk_sys::ANativeWindow,
|
||||
*const std::ffi::c_char,
|
||||
) -> *mut ASurfaceControl;
|
||||
type AcReleaseFn = unsafe extern "C" fn(*mut ASurfaceControl);
|
||||
type TxnCreateFn = unsafe extern "C" fn() -> *mut ASurfaceTransaction;
|
||||
type TxnDeleteFn = unsafe extern "C" fn(*mut ASurfaceTransaction);
|
||||
type TxnApplyFn = unsafe extern "C" fn(*mut ASurfaceTransaction);
|
||||
type TxnSetBufferFn = unsafe extern "C" fn(
|
||||
*mut ASurfaceTransaction,
|
||||
*mut ASurfaceControl,
|
||||
*mut ndk_sys::AHardwareBuffer,
|
||||
RawFd,
|
||||
);
|
||||
type TxnSetVisibilityFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i8);
|
||||
type TxnSetZOrderFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i32);
|
||||
type TxnSetGeometryFn = unsafe extern "C" fn(
|
||||
*mut ASurfaceTransaction,
|
||||
*mut ASurfaceControl,
|
||||
*const ARect,
|
||||
*const ARect,
|
||||
i32,
|
||||
);
|
||||
type TxnSetDesiredPresentTimeFn = unsafe extern "C" fn(*mut ASurfaceTransaction, i64);
|
||||
type TxnSetBufferDataSpaceFn =
|
||||
unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i32);
|
||||
type TxnSetFrameRateFn =
|
||||
unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, f32, i8);
|
||||
type OnCompleteCb = unsafe extern "C" fn(*mut c_void, *mut ASurfaceTransactionStats);
|
||||
type TxnSetOnCompleteFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut c_void, OnCompleteCb);
|
||||
type StatsGetLatchTimeFn = unsafe extern "C" fn(*mut ASurfaceTransactionStats) -> i64;
|
||||
type StatsGetPrevReleaseFenceFn =
|
||||
unsafe extern "C" fn(*mut ASurfaceTransactionStats, *mut ASurfaceControl) -> RawFd;
|
||||
|
||||
struct Api {
|
||||
create_from_window: CreateFromWindowFn,
|
||||
ac_release: AcReleaseFn,
|
||||
txn_create: TxnCreateFn,
|
||||
txn_delete: TxnDeleteFn,
|
||||
txn_apply: TxnApplyFn,
|
||||
txn_set_buffer: TxnSetBufferFn,
|
||||
txn_set_visibility: TxnSetVisibilityFn,
|
||||
txn_set_z_order: TxnSetZOrderFn,
|
||||
txn_set_geometry: TxnSetGeometryFn,
|
||||
txn_set_present_time: TxnSetDesiredPresentTimeFn,
|
||||
/// `setBufferDataSpace` is present from API 29 in practice but historically under-declared —
|
||||
/// resolved optionally, so an SDR stream (which never touches it) works even where it is absent.
|
||||
txn_set_dataspace: Option<TxnSetBufferDataSpaceFn>,
|
||||
/// `setFrameRate` is **API 30** — optional, `None` on API 29.
|
||||
txn_set_frame_rate: Option<TxnSetFrameRateFn>,
|
||||
txn_set_on_complete: TxnSetOnCompleteFn,
|
||||
stats_latch_time: StatsGetLatchTimeFn,
|
||||
stats_prev_release_fence: StatsGetPrevReleaseFenceFn,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
/// Resolve the whole `ASurface*` table from `libandroid.so`, or `None` on API < 29 (any required
|
||||
/// symbol absent). The two optional entries (`setBufferDataSpace`, `setFrameRate`) do not gate.
|
||||
fn resolve() -> Option<Api> {
|
||||
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never
|
||||
// closed — a process-lifetime handle). Each `dlsym` returns null when the symbol is absent
|
||||
// (device below API 29), checked before transmuting the non-null pointer to its fn type.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let req = |name: &std::ffi::CStr| -> Option<*mut c_void> {
|
||||
let p = libc::dlsym(lib, name.as_ptr());
|
||||
(!p.is_null()).then_some(p)
|
||||
};
|
||||
Some(Api {
|
||||
create_from_window: std::mem::transmute::<*mut c_void, CreateFromWindowFn>(req(
|
||||
c"ASurfaceControl_createFromWindow",
|
||||
)?),
|
||||
ac_release: std::mem::transmute::<*mut c_void, AcReleaseFn>(req(
|
||||
c"ASurfaceControl_release",
|
||||
)?),
|
||||
txn_create: std::mem::transmute::<*mut c_void, TxnCreateFn>(req(
|
||||
c"ASurfaceTransaction_create",
|
||||
)?),
|
||||
txn_delete: std::mem::transmute::<*mut c_void, TxnDeleteFn>(req(
|
||||
c"ASurfaceTransaction_delete",
|
||||
)?),
|
||||
txn_apply: std::mem::transmute::<*mut c_void, TxnApplyFn>(req(
|
||||
c"ASurfaceTransaction_apply",
|
||||
)?),
|
||||
txn_set_buffer: std::mem::transmute::<*mut c_void, TxnSetBufferFn>(req(
|
||||
c"ASurfaceTransaction_setBuffer",
|
||||
)?),
|
||||
txn_set_visibility: std::mem::transmute::<*mut c_void, TxnSetVisibilityFn>(req(
|
||||
c"ASurfaceTransaction_setVisibility",
|
||||
)?),
|
||||
txn_set_z_order: std::mem::transmute::<*mut c_void, TxnSetZOrderFn>(req(
|
||||
c"ASurfaceTransaction_setZOrder",
|
||||
)?),
|
||||
txn_set_geometry: std::mem::transmute::<*mut c_void, TxnSetGeometryFn>(req(
|
||||
c"ASurfaceTransaction_setGeometry",
|
||||
)?),
|
||||
txn_set_present_time: std::mem::transmute::<*mut c_void, TxnSetDesiredPresentTimeFn>(
|
||||
req(c"ASurfaceTransaction_setDesiredPresentTime")?,
|
||||
),
|
||||
txn_set_dataspace: req(c"ASurfaceTransaction_setBufferDataSpace")
|
||||
.map(|p| std::mem::transmute::<*mut c_void, TxnSetBufferDataSpaceFn>(p)),
|
||||
txn_set_frame_rate: req(c"ASurfaceTransaction_setFrameRate")
|
||||
.map(|p| std::mem::transmute::<*mut c_void, TxnSetFrameRateFn>(p)),
|
||||
txn_set_on_complete: std::mem::transmute::<*mut c_void, TxnSetOnCompleteFn>(req(
|
||||
c"ASurfaceTransaction_setOnComplete",
|
||||
)?),
|
||||
stats_latch_time: std::mem::transmute::<*mut c_void, StatsGetLatchTimeFn>(req(
|
||||
c"ASurfaceTransactionStats_getLatchTime",
|
||||
)?),
|
||||
stats_prev_release_fence: std::mem::transmute::<
|
||||
*mut c_void,
|
||||
StatsGetPrevReleaseFenceFn,
|
||||
>(req(
|
||||
c"ASurfaceTransactionStats_getPreviousReleaseFenceFd",
|
||||
)?),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ASurfaceControl` handle, reference-counted so it outlives every in-flight transaction. The
|
||||
/// layer holds one `Arc`; each pending completion callback's context holds another. `release` is
|
||||
/// called exactly once — when the layer is dropped AND the last outstanding callback has fired — so
|
||||
/// a completion that lands after teardown never indexes a freed control (the render-callback
|
||||
/// reclaim hazard, in the transaction world).
|
||||
struct ScHandle {
|
||||
sc: *mut ASurfaceControl,
|
||||
release: AcReleaseFn,
|
||||
}
|
||||
|
||||
// SAFETY: `sc` is only ever passed back to `ASurface*` C entry points (never dereferenced in Rust),
|
||||
// and its release is serialised by the `Arc` refcount reaching zero on whichever thread drops last.
|
||||
unsafe impl Send for ScHandle {}
|
||||
// SAFETY: as above — the raw handle is opaque to Rust and only handed to the thread-safe `ASurface*`
|
||||
// C API; shared read access across threads (the completion callback) never mutates it.
|
||||
unsafe impl Sync for ScHandle {}
|
||||
|
||||
impl Drop for ScHandle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: created by `createFromWindow`; the `Arc` guarantees this is the sole, final release
|
||||
// and that no transaction or callback still references `sc`.
|
||||
unsafe { (self.release)(self.sc) };
|
||||
}
|
||||
}
|
||||
|
||||
/// One presented transaction's real feedback, posted from the completion callback (a binder thread)
|
||||
/// into the decode loop's event channel. The loop matches `seq` to the buffer it retired and frees
|
||||
/// it once `prev_release_fence` signals.
|
||||
pub(super) struct PresentComplete {
|
||||
/// The presenter's monotonically increasing submit sequence for this transaction.
|
||||
pub seq: u64,
|
||||
/// SurfaceFlinger's latch instant for this frame (`CLOCK_MONOTONIC` ns) — the truthful present
|
||||
/// clock: consecutive latches are one true panel period apart, and `latch − release` is the
|
||||
/// real `latch` stat, both of which the predicted path could only guess at.
|
||||
pub latch_ns: i64,
|
||||
/// The release fence for the buffer this transaction REPLACED (the previous frame on the
|
||||
/// layer), or `None` when the platform reports none. The loop deletes that buffer's image with
|
||||
/// this fence so it is returned to the reader's pool only once SurfaceFlinger is done with it.
|
||||
pub prev_release_fence: Option<OwnedFd>,
|
||||
}
|
||||
|
||||
/// The completion callback's per-transaction context, leaked as a raw pointer into
|
||||
/// `setOnComplete` and reclaimed inside the callback (which fires exactly once per applied
|
||||
/// transaction). Carries only `Send` data so the binder-thread callback is sound.
|
||||
struct CompleteCtx {
|
||||
tx: mpsc::Sender<DecodeEvent>,
|
||||
seq: u64,
|
||||
/// A shared reference to the layer's `ASurfaceControl`, needed to read the per-surface release
|
||||
/// fence out of the stats. Holding the `Arc` keeps the control alive for the callback even if
|
||||
/// the layer was already dropped.
|
||||
sc: Arc<ScHandle>,
|
||||
prev_fence_fn: StatsGetPrevReleaseFenceFn,
|
||||
latch_fn: StatsGetLatchTimeFn,
|
||||
}
|
||||
|
||||
/// The `ASurfaceTransaction_OnComplete` trampoline (a binder thread). Reclaims its leaked context,
|
||||
/// reads the real latch time + the previous buffer's release fence, and forwards them to the decode
|
||||
/// loop. Panic-free by construction (an unwind out of an `extern "C"` fn would abort the process).
|
||||
unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTransactionStats) {
|
||||
if context.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `context` is the `Box<CompleteCtx>` leaked in `Layer::present`; the platform delivers
|
||||
// it exactly once per applied transaction, so this single reclaim is correct.
|
||||
let ctx = unsafe { Box::from_raw(context as *mut CompleteCtx) };
|
||||
let latch_ns = if stats.is_null() {
|
||||
0
|
||||
} else {
|
||||
// SAFETY: `stats` is valid for the duration of this callback (platform contract).
|
||||
unsafe { (ctx.latch_fn)(stats) }
|
||||
};
|
||||
let prev_release_fence = if stats.is_null() {
|
||||
None
|
||||
} else {
|
||||
// SAFETY: valid stats + the layer's live `ASurfaceControl`; a returned fd is owned by us
|
||||
// and closed via `OwnedFd`. `-1` means no fence.
|
||||
let fd = unsafe { (ctx.prev_fence_fn)(stats, ctx.sc.sc) };
|
||||
// SAFETY: a non-negative fd returned by `getPreviousReleaseFenceFd` is a fresh owned fence
|
||||
// descriptor whose ownership the API transfers to us; wrapping it in `OwnedFd` closes it.
|
||||
(fd >= 0).then(|| unsafe { OwnedFd::from_raw_fd(fd) })
|
||||
};
|
||||
let _ = ctx.tx.send(DecodeEvent::PresentComplete(PresentComplete {
|
||||
seq: ctx.seq,
|
||||
latch_ns,
|
||||
prev_release_fence,
|
||||
}));
|
||||
}
|
||||
|
||||
/// One `ASurfaceControl` layer, a child of the SurfaceView's window, that the presenter composites
|
||||
/// decoded buffers onto. Owns nothing thread-shared; lives on and is dropped by the decode loop.
|
||||
pub(super) struct Layer {
|
||||
api: Api,
|
||||
sc: Arc<ScHandle>,
|
||||
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
|
||||
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
|
||||
/// because the view resizes under a surface that is never recreated (see `dest`).
|
||||
surface_size: Arc<AtomicU64>,
|
||||
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
|
||||
/// view when video started): the window's own buffer geometry, the best remaining guess.
|
||||
fallback_w: i32,
|
||||
fallback_h: i32,
|
||||
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
|
||||
configured: bool,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
|
||||
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
|
||||
///
|
||||
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
|
||||
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
|
||||
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
|
||||
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
|
||||
/// using it shrank the picture to the top-left corner. It is read fresh on every present
|
||||
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
|
||||
/// screen hides the system bars and switches on cutout drawing a frame or two after
|
||||
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
|
||||
/// view yet) falls back to the buffer size as the best remaining guess.
|
||||
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
|
||||
let api = Api::resolve()?;
|
||||
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
|
||||
// static NUL-terminated string; the call returns null on failure (checked).
|
||||
let sc =
|
||||
unsafe { (api.create_from_window)(window.ptr().as_ptr(), c"punktfunk-video".as_ptr()) };
|
||||
if sc.is_null() {
|
||||
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
|
||||
return None;
|
||||
}
|
||||
let fallback_w = window.width().max(1);
|
||||
let fallback_h = window.height().max(1);
|
||||
log::info!(
|
||||
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
|
||||
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
|
||||
);
|
||||
Some(Layer {
|
||||
sc: Arc::new(ScHandle {
|
||||
sc,
|
||||
release: api.ac_release,
|
||||
}),
|
||||
api,
|
||||
surface_size,
|
||||
fallback_w,
|
||||
fallback_h,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The destination rectangle for this present: the live view size, or the window's buffer
|
||||
/// geometry while Kotlin has reported nothing.
|
||||
fn dest(&self) -> (i32, i32) {
|
||||
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
|
||||
.unwrap_or((self.fallback_w, self.fallback_h))
|
||||
}
|
||||
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
/// tagged with `seq`. `dataspace` is the `ADataSpace` value (`0` = leave the layer default —
|
||||
/// only the `setBufferDataSpace`-less API-29 fallback ever presents untagged).
|
||||
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
|
||||
/// could not be created (the caller then frees the buffer itself).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn present(
|
||||
&mut self,
|
||||
buffer: &HardwareBuffer,
|
||||
src_w: i32,
|
||||
src_h: i32,
|
||||
acquire_fence: Option<OwnedFd>,
|
||||
desired_present_ns: i64,
|
||||
dataspace: i32,
|
||||
frame_rate: f32,
|
||||
seq: u64,
|
||||
ev_tx: &mpsc::Sender<DecodeEvent>,
|
||||
) -> bool {
|
||||
// SAFETY: `txn_create` returns a fresh transaction or null; every setter below takes that
|
||||
// transaction + this layer's live `sc` + valid arguments; `apply`/`delete` consume it once.
|
||||
unsafe {
|
||||
let txn = (self.api.txn_create)();
|
||||
if txn.is_null() {
|
||||
// The acquire fence would leak if we returned without consuming it.
|
||||
drop(acquire_fence);
|
||||
return false;
|
||||
}
|
||||
let sc = self.sc.sc;
|
||||
let fence_fd = acquire_fence
|
||||
.map(std::os::fd::IntoRawFd::into_raw_fd)
|
||||
.unwrap_or(-1);
|
||||
(self.api.txn_set_buffer)(txn, sc, buffer.as_ptr(), fence_fd);
|
||||
let src = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: src_w.max(1),
|
||||
bottom: src_h.max(1),
|
||||
};
|
||||
let (dest_w, dest_h) = self.dest();
|
||||
let dst = ARect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: dest_w,
|
||||
bottom: dest_h,
|
||||
};
|
||||
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
|
||||
if dataspace != 0 {
|
||||
if let Some(f) = self.api.txn_set_dataspace {
|
||||
f(txn, sc, dataspace);
|
||||
}
|
||||
}
|
||||
if !self.configured {
|
||||
(self.api.txn_set_visibility)(txn, sc, VISIBILITY_SHOW);
|
||||
(self.api.txn_set_z_order)(txn, sc, 0);
|
||||
// Declare the layer as fixed-rate video at the source rate (compatibility 1 =
|
||||
// FIXED_SOURCE) so a compliant display aligns its refresh to it. Best-effort: an
|
||||
// LTPO governor may still run "video" content below its own floor for power (the
|
||||
// NP3 does — no app-side rate hint raises its render-range floor; the display's
|
||||
// Minimum-refresh-rate system setting is the only lever there).
|
||||
if frame_rate > 0.0 {
|
||||
if let Some(f) = self.api.txn_set_frame_rate {
|
||||
f(txn, sc, frame_rate, 1);
|
||||
}
|
||||
}
|
||||
self.configured = true;
|
||||
}
|
||||
(self.api.txn_set_present_time)(txn, desired_present_ns);
|
||||
// One-shot completion context, reclaimed inside the callback. The `Arc` clone keeps the
|
||||
// control alive for the callback even past the layer's own drop.
|
||||
let ctx = Box::into_raw(Box::new(CompleteCtx {
|
||||
tx: ev_tx.clone(),
|
||||
seq,
|
||||
sc: self.sc.clone(),
|
||||
prev_fence_fn: self.api.stats_prev_release_fence,
|
||||
latch_fn: self.api.stats_latch_time,
|
||||
}));
|
||||
(self.api.txn_set_on_complete)(txn, ctx as *mut c_void, on_complete);
|
||||
(self.api.txn_apply)(txn);
|
||||
(self.api.txn_delete)(txn);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ pub(super) fn run_sync(
|
||||
present_priority: _,
|
||||
smooth_buffer: _,
|
||||
panel_hz: _,
|
||||
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
|
||||
// SurfaceView, so it never needs the view's on-screen size.
|
||||
surface_size: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -30,6 +30,20 @@ use jni::EnvUnowned;
|
||||
mod adpf;
|
||||
#[cfg(target_os = "android")]
|
||||
mod audio;
|
||||
// The Skia console UI host (design/android-skia-console-port.md): the shared `pf-console-ui`
|
||||
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
|
||||
#[cfg(target_os = "android")]
|
||||
mod console;
|
||||
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
|
||||
#[cfg(target_os = "android")]
|
||||
mod logs;
|
||||
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
|
||||
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
|
||||
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
|
||||
// Nothing in it touches AAudio. `test`-gated for the host build on top of the Android one so the
|
||||
// off-device leg still compiles and runs the proof; `audio` is its only non-test user.
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
mod audio_format;
|
||||
#[cfg(target_os = "android")]
|
||||
mod decode;
|
||||
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
|
||||
@@ -49,22 +63,58 @@ mod wol;
|
||||
// it off the main thread to light saved-host "online" pips independently of mDNS.
|
||||
mod probe;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
|
||||
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
|
||||
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
|
||||
/// target, message) so a bundle reads the same on the host's Logs page whichever client
|
||||
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
|
||||
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
|
||||
#[cfg(target_os = "android")]
|
||||
struct RingTee(android_logger::AndroidLogger);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl log::Log for RingTee {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
self.0.enabled(metadata)
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.0.log(record);
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
pf_client_core::logring::wallclock(),
|
||||
record.level().as_str(),
|
||||
record.target(),
|
||||
record.args()
|
||||
));
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.0.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
|
||||
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
|
||||
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
|
||||
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
|
||||
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
|
||||
/// JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
) -> jint {
|
||||
android_logger::init_once(
|
||||
let logcat = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("punktfunk"),
|
||||
);
|
||||
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
|
||||
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
}
|
||||
log::info!(
|
||||
"punktfunk_android loaded (core ABI v{})",
|
||||
punktfunk_core::ABI_VERSION
|
||||
@@ -92,3 +142,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'loca
|
||||
env.with_env(|env| env.new_string(env!("CARGO_PKG_VERSION")))
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleAvailable(): Boolean` — whether this `.so` carries the Skia
|
||||
/// console host ([`console`]). Kotlin asks before it calls any `nativeConsole*` symbol, so a
|
||||
/// build that ever drops the host on some ABI again degrades to the touch UI rather than an
|
||||
/// `UnsatisfiedLinkError`. Today: every Android ABI.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleAvailable(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jboolean {
|
||||
cfg!(target_os = "android")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
|
||||
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
|
||||
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
|
||||
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
|
||||
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
|
||||
//! because `pf-client-core` is an Android-target dependency of this crate.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::EnvUnowned;
|
||||
|
||||
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
|
||||
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
|
||||
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
|
||||
/// thread, though the caller is about to do network anyway.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
header: JString<'local>,
|
||||
) -> JString<'local> {
|
||||
env.with_env(|env| {
|
||||
let header: String = header.try_to_string(env)?;
|
||||
env.new_string(pf_client_core::logring::render(&header))
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
@@ -102,9 +102,131 @@ fn force_parts_sysprop() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The rates this session may ask for when the one the user chose will not open, best first.
|
||||
///
|
||||
/// **Down the requested rate's own FAMILY, then the 48 kHz floor.** The two families
|
||||
/// ([`punktfunk_core::audio::pcm::rate_is_supported`]) are 44.1 / 88.2 / 176.4 kHz and 48 / 96 kHz,
|
||||
/// and within a family the lower rates are the same material at half the samples — a 176.4 kHz
|
||||
/// interface that will not open is overwhelmingly likely to be an 88.2 or 44.1 kHz one, and asking
|
||||
/// there next is asking for the rate the endpoint most plausibly runs at.
|
||||
///
|
||||
/// **48 kHz terminates every ladder, including the 44.1 family's**, and that crossing is deliberate
|
||||
/// rather than an oversight. It is the rate every Android output grants, it is the rate this
|
||||
/// protocol has always run, and the alternative to a 48 kHz *lossless* session is a 48 kHz *Opus*
|
||||
/// one — the same rate with a lossy stage added. Nothing is resampled by this decision: the host
|
||||
/// captures at the rate it answers with, or declines (§8.2/§8.3), so a 44.1 kHz-locked endpoint
|
||||
/// answers a 48 kHz request with Opus rather than with a quiet upsample.
|
||||
///
|
||||
/// The requested rate is the first rung, so an openable rate is asked for unchanged and a default
|
||||
/// session's ladder is one rung long.
|
||||
fn rate_fallback_ladder(rate_hz: u32) -> &'static [u32] {
|
||||
const HZ48: u32 = punktfunk_core::audio::SAMPLE_RATE_HZ;
|
||||
match rate_hz {
|
||||
176_400 => &[176_400, 88_200, 44_100, HZ48],
|
||||
88_200 => &[88_200, 44_100, HZ48],
|
||||
44_100 => &[44_100, HZ48],
|
||||
96_000 => &[96_000, HZ48],
|
||||
// 48 kHz itself, and — via the `rate_is_supported` guard in the caller — nothing else.
|
||||
_ => &[HZ48],
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the audio format this `Hello` should ASK for, from what Kotlin's setting requested —
|
||||
/// after proving this device can actually open it.
|
||||
///
|
||||
/// This is `design/hi-res-audio.md` §7's rule made mechanical: *"a client that cannot open a
|
||||
/// 96 kHz output must not set `CLIENT_CAP_AUDIO_HIRES`"*. It has to happen here, before the
|
||||
/// handshake, because after it there is no recovery: AAudio grants an explicitly-requested rate or
|
||||
/// fails the open (it never substitutes), the host does not renegotiate the plane mid-session
|
||||
/// (§6), and the only ways to play a wire of one rate through a stream of another are the wrong
|
||||
/// speed or a resampler nobody asked for — which §9 forbids in as many words ("say so and fall
|
||||
/// back, not resample quietly"). So the fall back happens where falling back is still free: in the
|
||||
/// request.
|
||||
///
|
||||
/// **Every rung above the floor is probed, and admitting the 44.1 kHz family made that matter
|
||||
/// more, not less.** When the ladder was 96 → 48 there was one uncertain rate; now there are four,
|
||||
/// and their odds are nothing alike — 44 100 Hz is granted by very nearly every Android output,
|
||||
/// 176 400 Hz by very nearly none, and 88 200 Hz by whatever the HAL happens to think. None of
|
||||
/// that is inferable from the number, so [`crate::audio::output_rate_is_openable`] opens a stream
|
||||
/// and reads back what it was granted, once per rung, until one holds.
|
||||
///
|
||||
/// Dropping the RATE keeps the depth, so a device that refuses the rate still gets a 24-bit
|
||||
/// lossless session rather than being pushed all the way back to Opus — the depth is where the
|
||||
/// plane earns its bandwidth anyway (and it is the half that is audible at all: §12).
|
||||
///
|
||||
/// The 48 kHz floor is never probed. It is universally supported, and the DEPTH never reaches
|
||||
/// AAudio at all (the device is opened as f32 on both planes — see `crate::audio`), so there is
|
||||
/// nothing about 16-vs-24-bit for a probe to discover. An ordinary session therefore opens no
|
||||
/// stream here and pays nothing.
|
||||
///
|
||||
/// # ⚠⚠ "Not asking" is `(0, 0)`, and it is NOT `(48 000, 16)`
|
||||
///
|
||||
/// Core's `advertised_client_caps` sets `CLIENT_CAP_AUDIO_HIRES` when **either field is non-zero**
|
||||
/// — it keys on *the caller specified a format*, not on *the format differs from the default*, and
|
||||
/// deliberately: 48 kHz/16-bit is the cheapest lossless rung as well as the legacy pair, so a
|
||||
/// "differs from the default" rule would make it the one rung on the ladder nobody could ask for.
|
||||
///
|
||||
/// So returning the legacy-looking `(48 000, 16)` for a user who chose **Standard (Opus)** does not
|
||||
/// mean "no request" — it advertises the capability, the host's gate accepts 48 kHz/16-bit as a
|
||||
/// perfectly supported format, and the host then silently gives that user the lossless `0xD3` plane
|
||||
/// at 1.5 Mbps in place of 256 kbps of Opus. This returned exactly that pair until it was caught by
|
||||
/// comparing all four clients; the desktop client and every pre-v24 `punktfunk_connect_ex*` send
|
||||
/// `(0, 0)`, and so does this now.
|
||||
///
|
||||
/// ⚠⚠ The reach of that slip grew on 2026-08-17: `PUNKTFUNK_AUDIO_HIRES` went default-ON, so where
|
||||
/// this used to need a host whose operator had opted in, it now lands on every host that has not
|
||||
/// deliberately opted out.
|
||||
///
|
||||
/// `(0, 0)` is also what keeps the `Hello` byte-identical to a legacy one, because the wire encodes
|
||||
/// an explicit 48 000/16 the same as absent — the difference lives entirely in the capability bit.
|
||||
fn resolve_requested_audio_format(rate_hz: u32, bits: u8, channels: u8) -> (u32, u8) {
|
||||
const HZ48: u32 = punktfunk_core::audio::SAMPLE_RATE_HZ;
|
||||
/// "This session did not ask for the lossless plane" — see the ⚠⚠ section above for why this
|
||||
/// is a pair of zeroes and not the legacy 48 000/16.
|
||||
const UNSPECIFIED: (u32, u8) = (0, 0);
|
||||
// A format core would not carry — including Kotlin's `0`/`0` for the Opus setting — asks for
|
||||
// nothing, rather than being an error: the request is a preference, and an unrecognized one
|
||||
// must not block a connect. The rate set comes from core rather than being re-expressed here,
|
||||
// so the host's gate and every client's request validation cannot drift apart.
|
||||
if !punktfunk_core::audio::pcm::depth_is_supported(bits)
|
||||
|| !punktfunk_core::audio::pcm::rate_is_supported(rate_hz)
|
||||
{
|
||||
return UNSPECIFIED;
|
||||
}
|
||||
let granted = rate_fallback_ladder(rate_hz)
|
||||
.iter()
|
||||
.copied()
|
||||
// `HZ48` short-circuits the probe rather than being trusted after one: it is the ladder's
|
||||
// floor, so a probe there could only turn a working session into no lossless session at
|
||||
// all — and it is the rate a failed probe would have fallen back TO.
|
||||
.find(|&hz| hz == HZ48 || audio_rate_is_openable(hz, channels))
|
||||
// Unreachable while every ladder ends at `HZ48`; the belt is here so a future rung added
|
||||
// above the floor cannot silently produce an unrequestable format.
|
||||
.unwrap_or(HZ48);
|
||||
if granted != rate_hz {
|
||||
log::warn!(
|
||||
"audio: this device will not open a {rate_hz} Hz output, so the session asks for {granted} Hz / {bits}-bit instead — the wire is only ever offered a format this client has proved it can play"
|
||||
);
|
||||
}
|
||||
(granted, bits)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn audio_rate_is_openable(rate_hz: u32, channels: u8) -> bool {
|
||||
crate::audio::output_rate_is_openable(rate_hz, channels)
|
||||
}
|
||||
|
||||
/// Off-device (the host `cargo build --workspace` leg, where there is no AAudio at all): nothing
|
||||
/// can be proved, so nothing is claimed. The caller falls back to the legacy rate, which is the
|
||||
/// safe answer for a build that never runs on a phone anyway.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn audio_rate_is_openable(_rate_hz: u32, _channels: u8) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
|
||||
/// deviceName): Long`.
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, audioRateHz, audioBits, preferredCodec,
|
||||
/// timeoutMs, launch, deviceName): Long`.
|
||||
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
||||
/// `deviceName` (empty ⇒ none) rides the Hello as `name` — what the host's pending-approval list
|
||||
/// and trust store show for this device (Kotlin passes `Build.MODEL`, its `nativePair` convention).
|
||||
@@ -113,6 +235,14 @@ fn force_parts_sysprop() -> bool {
|
||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||
/// wire bytes (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8;
|
||||
/// normalized, anything else → stereo) — the host clamps it and the resolved count drives playback.
|
||||
/// `audioRateHz`/`audioBits` are the audio FORMAT asked for. **`0`/`0` — and anything unrecognized
|
||||
/// — is "did not ask", the legacy Opus request every build has made**; any pair core can carry asks
|
||||
/// the host for the lossless `0xD3` plane, `48000`/`16` INCLUDED (that is the cheapest lossless
|
||||
/// rung, not a spelling of "default" — see [`resolve_requested_audio_format`], which is where
|
||||
/// getting this backwards silently upgraded every Opus session). Only a request; the host's gate may
|
||||
/// answer Opus regardless, and this device may not be able to open the rate at all, which is what
|
||||
/// [`resolve_requested_audio_format`] settles HERE rather than letting the session negotiate a wire
|
||||
/// it cannot play.
|
||||
/// `preferredCodec` is the soft codec preference wire byte (0 = Auto). `timeoutMs` is the handshake
|
||||
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
|
||||
/// host's approval-park window) so a slow operator approval lands on this same parked connection
|
||||
@@ -137,6 +267,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
multi_slice_ok: jboolean,
|
||||
frame_parts_ok: jboolean,
|
||||
audio_channels: jint,
|
||||
audio_rate_hz: jint,
|
||||
audio_bits: jint,
|
||||
video_codecs: jint,
|
||||
preferred_codec: jint,
|
||||
timeout_ms: jint,
|
||||
@@ -222,7 +354,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
height: height as u32,
|
||||
refresh_hz: refresh_hz as u32,
|
||||
};
|
||||
match NativeClient::connect(
|
||||
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1); anything else is stereo. The
|
||||
// host clamps it and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`).
|
||||
let audio_channels =
|
||||
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8);
|
||||
// The audio format, downgraded to something this device has PROVED it can open before the
|
||||
// `Hello` carries it — see `resolve_requested_audio_format` for why it cannot wait until
|
||||
// playback. `clamp` first: a negative jint from a corrupted setting must not wrap into a
|
||||
// plausible rate.
|
||||
let (audio_rate_hz, audio_bits) = resolve_requested_audio_format(
|
||||
audio_rate_hz.max(0) as u32,
|
||||
audio_bits.clamp(0, u8::MAX as jint) as u8,
|
||||
audio_channels,
|
||||
);
|
||||
match NativeClient::connect_with_audio_format(
|
||||
&host,
|
||||
port as u16,
|
||||
mode,
|
||||
@@ -248,11 +394,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
||||
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||
// normalizes to stereo here.
|
||||
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
||||
audio_channels,
|
||||
// The audio format this session ASKS for (resolved above). A non-default pair is what
|
||||
// makes core set `CLIENT_CAP_AUDIO_HIRES` in the `Hello` — capable AND the user turned it
|
||||
// on, the `VIDEO_CAP_444` precedent — and it is answered by the host re-formatting the
|
||||
// wire, so it must never be advertised on a device that cannot open the output. The host
|
||||
// may still decline; `connector.audio_codec`/`audio_sample_rate_hz`/`audio_bits` are what
|
||||
// actually happened, and `crate::audio` opens the device from those, never from these.
|
||||
audio_rate_hz,
|
||||
audio_bits,
|
||||
// Codecs this device can decode, ranked on the Kotlin side (`VideoDecoders.decodableCodecBits`:
|
||||
// H.264 + HEVC always, AV1 when a real `video/av01` decoder exists — AMediaCodec is
|
||||
// mime-driven, see `codec_mime`). Mask to the known bits and fall back to the pre-AV1
|
||||
@@ -301,6 +451,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||
// The Kotlin side cancels by dropping the result (`Dial.cancelled`), not by aborting
|
||||
// the dial — its connect runs on a pool thread, so a parked one costs a thread, not a
|
||||
// stuck UI. Wire a flag through here if that ever stops being true.
|
||||
None,
|
||||
) {
|
||||
Ok(client) => {
|
||||
let handle = SessionHandle {
|
||||
@@ -316,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
|
||||
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
@@ -490,3 +646,166 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::audio::pcm::{BITS_16, BITS_24};
|
||||
use punktfunk_core::audio::SAMPLE_RATE_HZ;
|
||||
|
||||
/// The rule this leg exists to enforce: the `Hello` never asks for an audio format this device
|
||||
/// has not proved it can open, because after the handshake there is no way back — AAudio grants
|
||||
/// an explicit rate or fails the open, the host does not renegotiate the plane mid-session, and
|
||||
/// playing a 96 kHz wire through a 48 kHz stream is not a fallback, it is the wrong audio.
|
||||
///
|
||||
/// Off-device (this test's target) `audio_rate_is_openable` answers `false` for everything, so
|
||||
/// what is pinned here is the DOWNGRADE, which is the half that has to be right: a device that
|
||||
/// cannot do the rate still gets a lossless session at 48 kHz rather than being pushed all the
|
||||
/// way back to Opus, and the depth — the thing lossless is actually for — survives.
|
||||
#[test]
|
||||
fn an_unopenable_rate_is_downgraded_before_the_hello_and_keeps_its_depth() {
|
||||
// 48 kHz/16-bit is the cheapest LOSSLESS rung, not a way of spelling "default" — asking
|
||||
// for it explicitly passes through and probes nothing. What a default session sends is
|
||||
// `(0, 0)`, pinned in `an_opus_session_asks_for_nothing_and_a_lossless_one_asks_for_
|
||||
// something`, and conflating the two is what silently upgraded every Opus user.
|
||||
assert_eq!(
|
||||
resolve_requested_audio_format(SAMPLE_RATE_HZ, BITS_16, 2),
|
||||
(SAMPLE_RATE_HZ, BITS_16)
|
||||
);
|
||||
// 48 kHz is never probed, so 48/24 lossless survives even where nothing can be opened.
|
||||
assert_eq!(
|
||||
resolve_requested_audio_format(SAMPLE_RATE_HZ, BITS_24, 2),
|
||||
(SAMPLE_RATE_HZ, BITS_24)
|
||||
);
|
||||
// Every rung above the floor IS probed, is refused here, and lands on 48 kHz with the
|
||||
// depth intact — including the whole 44.1 kHz family, which this pass admitted. AAudio
|
||||
// never substitutes a rate, so a device that would not grant 176 400 Hz and was asked for
|
||||
// it anyway is silence, not a slower session.
|
||||
for rate in [44_100u32, 88_200, 96_000, 176_400] {
|
||||
assert_eq!(
|
||||
resolve_requested_audio_format(rate, BITS_24, 2),
|
||||
(SAMPLE_RATE_HZ, BITS_24),
|
||||
"{rate} Hz should have fallen to 48 kHz and kept 24-bit"
|
||||
);
|
||||
}
|
||||
// Surround asks exactly as stereo does. The lossless plane was stereo-only while a
|
||||
// surround frame did not fit a datagram; the frame ladder is channel-aware, the host
|
||||
// decides, and this leg's only job is to prove the OUTPUT opens (at the layout it will
|
||||
// actually be opened with).
|
||||
assert_eq!(
|
||||
resolve_requested_audio_format(SAMPLE_RATE_HZ, BITS_24, 6),
|
||||
(SAMPLE_RATE_HZ, BITS_24)
|
||||
);
|
||||
}
|
||||
|
||||
/// A settings string, a profile written by a newer build, or a corrupted preference must never
|
||||
/// reach the wire as a format the plane cannot carry — and must never block a connect either.
|
||||
/// Every one resolves to the "did not ask" sentinel, which every host can answer.
|
||||
///
|
||||
/// The rate set is `pcm::rate_is_supported`'s, not a second copy of it: 44 100 Hz used to sit
|
||||
/// in this table because §4.1's integer samples-per-millisecond arithmetic could not express
|
||||
/// it, and the day that stopped being true a locally re-expressed set would have kept refusing
|
||||
/// it with a stale reason.
|
||||
#[test]
|
||||
fn an_unrepresentable_request_falls_back_instead_of_failing() {
|
||||
for (rate, bits) in [
|
||||
(0, 0), // Kotlin's Opus setting, and its "unset"
|
||||
(22_050, BITS_24), // below the ladder — a rate this protocol never negotiates
|
||||
(192_000, BITS_24), // out by §3's scope decision, not by any arithmetic
|
||||
(384_000, BITS_24), // above anything anyone has asked for
|
||||
(SAMPLE_RATE_HZ, 32), // 32-bit float is deliberately not on the wire
|
||||
(SAMPLE_RATE_HZ, 8), // not a depth this plane carries
|
||||
(176_400, 32), // a carried rate cannot rescue an uncarried depth
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_requested_audio_format(rate, bits, 2),
|
||||
(0, 0),
|
||||
"{rate} Hz / {bits}-bit should have asked for nothing at all"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠⚠ **The one that decides whether a user who chose Opus is quietly given 1.5 Mbps of PCM.**
|
||||
///
|
||||
/// Core's `advertised_client_caps` sets `CLIENT_CAP_AUDIO_HIRES` when **either** field of the
|
||||
/// pair below is non-zero. It keys on "a format was specified" rather than "the format differs
|
||||
/// from the default", and deliberately: 48 kHz/16-bit is the legacy pair AND the cheapest
|
||||
/// lossless rung, so the other rule would make that rung the one nobody could request.
|
||||
///
|
||||
/// The consequence is that `(48 000, 16)` is not a way of saying "default" — it is a request,
|
||||
/// the host's gate accepts it as a supported format, and the user who chose Standard gets the
|
||||
/// lossless `0xD3` plane instead of Opus on every host that has not opted out of it (which,
|
||||
/// since the host gate went default-ON on 2026-08-17, is all of them). Nothing surfaces it: the
|
||||
/// settings screen shows what was asked for, and a granted
|
||||
/// session and a declined one look identical from there. This function returned that pair until
|
||||
/// all four clients were compared against each other.
|
||||
///
|
||||
/// The rule is restated here rather than called, because it is private to core; core's own
|
||||
/// tests pin the other half of it. What this test owns is that **this** client never hands it a
|
||||
/// non-zero pair unless the user asked for one.
|
||||
#[test]
|
||||
fn an_opus_session_asks_for_nothing_and_a_lossless_one_asks_for_something() {
|
||||
// Core's rule, verbatim: `audio_rate_hz != 0 || audio_bits != 0`.
|
||||
let asks_for_hires = |(rate_hz, bits): (u32, u8)| rate_hz != 0 || bits != 0;
|
||||
|
||||
assert!(
|
||||
!asks_for_hires(resolve_requested_audio_format(0, 0, 2)),
|
||||
"the default session must not advertise the capability"
|
||||
);
|
||||
// Surround changes nothing about it: the plane is negotiated by format, not by layout.
|
||||
assert!(!asks_for_hires(resolve_requested_audio_format(0, 0, 8)));
|
||||
|
||||
// And every rung the settings screen offers does ask — otherwise the setting is inert.
|
||||
// The 48 kHz rows resolve unprobed; the rest fall to 48 kHz off-device (see above) and
|
||||
// still ask, because the fallback keeps the DEPTH and a 24-bit request is a real one.
|
||||
for rate in [44_100u32, SAMPLE_RATE_HZ, 88_200, 96_000, 176_400] {
|
||||
let wire = resolve_requested_audio_format(rate, BITS_24, 2);
|
||||
assert!(
|
||||
asks_for_hires(wire),
|
||||
"{rate} Hz / 24-bit resolved to {wire:?}, which asks for nothing"
|
||||
);
|
||||
assert_eq!(wire.1, BITS_24, "the depth must survive every fallback");
|
||||
}
|
||||
}
|
||||
|
||||
/// The fallback ladder's shape, which decides what a device that refuses the user's rate is
|
||||
/// asked for next — and which is the only place this client makes a quality choice on the
|
||||
/// user's behalf, so it is worth pinning rather than reading.
|
||||
#[test]
|
||||
fn the_rate_ladder_descends_its_own_family_and_ends_at_the_48_khz_floor() {
|
||||
for rate in [44_100u32, SAMPLE_RATE_HZ, 88_200, 96_000, 176_400] {
|
||||
let ladder = rate_fallback_ladder(rate);
|
||||
assert_eq!(ladder[0], rate, "{rate} Hz must ask for itself first");
|
||||
assert_eq!(
|
||||
ladder.last().copied(),
|
||||
Some(SAMPLE_RATE_HZ),
|
||||
"{rate} Hz must end at the floor every Android output grants"
|
||||
);
|
||||
for &rung in ladder {
|
||||
assert!(
|
||||
punktfunk_core::audio::pcm::rate_is_supported(rung),
|
||||
"{rung} Hz is on {rate} Hz's ladder but is not a rate the plane carries"
|
||||
);
|
||||
}
|
||||
// Strictly descending, so a fallback is never an upgrade in cost — except for the
|
||||
// 48 kHz floor itself, which is above 44 100 and is the crossing the ladder makes on
|
||||
// purpose (see `rate_fallback_ladder`).
|
||||
for w in ladder.windows(2) {
|
||||
assert!(
|
||||
w[1] < w[0] || w[1] == SAMPLE_RATE_HZ,
|
||||
"{rate} Hz's ladder goes up at {:?}",
|
||||
w
|
||||
);
|
||||
}
|
||||
}
|
||||
// The 44.1 family stays in the 44.1 family for as long as it can: a 176.4 kHz endpoint
|
||||
// that will not open is far likelier to be an 88.2 or 44.1 kHz one than a 96 kHz one.
|
||||
assert_eq!(
|
||||
rate_fallback_ladder(176_400),
|
||||
&[176_400, 88_200, 44_100, SAMPLE_RATE_HZ]
|
||||
);
|
||||
assert_eq!(rate_fallback_ladder(96_000), &[96_000, SAMPLE_RATE_HZ]);
|
||||
// A default session's ladder is one rung, so it opens no probe stream at all.
|
||||
assert_eq!(rate_fallback_ladder(SAMPLE_RATE_HZ), &[SAMPLE_RATE_HZ]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
|
||||
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
|
||||
/// sends, read by the ASurfaceControl presenter before each present.
|
||||
///
|
||||
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
|
||||
/// that is never recreated: hiding the system bars and switching the window to
|
||||
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
|
||||
/// and each one grows the video view. A destination rect captured once at creation then keeps
|
||||
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
|
||||
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
|
||||
/// falls back to the window's buffer geometry.
|
||||
pub surface_size: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
|
||||
/// single atomic load and can never see a torn pair (a new width against an old height).
|
||||
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
|
||||
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
|
||||
if packed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pack_surface_size, unpack_surface_size};
|
||||
|
||||
/// The pair the presenter reads as one atomic load must survive the round trip — including a
|
||||
/// size wider than a signed 16-bit value, which every panel this runs on now is.
|
||||
#[test]
|
||||
fn surface_size_round_trips() {
|
||||
assert_eq!(
|
||||
unpack_surface_size(pack_surface_size(2800, 1260)),
|
||||
Some((2800, 1260))
|
||||
);
|
||||
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
|
||||
}
|
||||
|
||||
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
|
||||
/// to the window's buffer geometry rather than composing into an empty rectangle.
|
||||
#[test]
|
||||
fn non_positive_sizes_are_the_sentinel() {
|
||||
assert_eq!(pack_surface_size(0, 0), 0);
|
||||
assert_eq!(pack_surface_size(1920, 0), 0);
|
||||
assert_eq!(pack_surface_size(-1, 1080), 0);
|
||||
assert_eq!(unpack_surface_size(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority: jni::sys::jint,
|
||||
smooth_buffer: jni::sys::jint,
|
||||
panel_fps: jni::sys::jint,
|
||||
surface_w: jni::sys::jint,
|
||||
surface_h: jni::sys::jint,
|
||||
) {
|
||||
use super::VideoThread;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -70,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
|
||||
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
|
||||
// current from here on (the bars hide and the cutout mode changes AFTER this call).
|
||||
h.surface_size.store(
|
||||
super::pack_surface_size(surface_w, surface_h),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature,
|
||||
@@ -78,6 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
surface_size: h.surface_size.clone(),
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -89,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
|
||||
/// on-screen pixel size, re-reported on every `surfaceChanged`.
|
||||
///
|
||||
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
|
||||
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
|
||||
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
|
||||
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
|
||||
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
|
||||
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
|
||||
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
|
||||
/// + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
|
||||
_env: EnvUnowned,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
width: jni::sys::jint,
|
||||
height: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
let packed = super::pack_surface_size(width, height);
|
||||
if handle == 0 || packed == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.surface_size
|
||||
.store(packed, std::sync::atomic::Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
@@ -174,12 +215,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 35 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 38 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs, audioCodec,
|
||||
/// audioRateHz, audioBits]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -202,8 +244,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||||
/// newest-wins pacing); 33/34 are the AUDIO plane's latency — the playback ring's live depth in ms
|
||||
/// and the A/V sync loop's smoothed offset in ms (positive = audio behind the picture) — both live
|
||||
/// gauges rather than windowed samples, like the cumulative drop total at 9), or `null` when no
|
||||
/// decode thread is running.
|
||||
/// gauges rather than windowed samples, like the cumulative drop total at 9; 35–37 are the audio
|
||||
/// FORMAT the host resolved at the handshake — `audioCodec` (`0` = Opus on `0xC9`, `2` = lossless
|
||||
/// PCM on `0xD3`), the resolved rate in Hz and the resolved depth in bits. Static for the session,
|
||||
/// and here because `design/hi-res-audio.md` §10 requires a surface for the RESOLVED format rather
|
||||
/// than the requested one: a session that spends 4.6 Mbps and a session whose host quietly
|
||||
/// declined look identical from the outside, which is §4.3's failure wearing a UI hat), or `null`
|
||||
/// when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
@@ -227,7 +274,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 35] = [
|
||||
let buf: [f64; 38] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -290,6 +337,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<
|
||||
// high" report had no instrument behind it at all.
|
||||
h.client.audio_buffer_ms() as f64,
|
||||
h.client.audio_av_offset_ms() as f64,
|
||||
// The audio format the host RESOLVED (`Welcome`), not what this device asked for.
|
||||
// A lossless session and a session whose host declined lossless are indistinguishable
|
||||
// from the outside — same picture, same latency figures, one of them quietly spending
|
||||
// 2.3–4.6 Mbps of the link on nothing — so the HUD has to be able to name which
|
||||
// (`design/hi-res-audio.md` §10, and §4.3 for why it matters). Static for the session:
|
||||
// the plane is settled at the handshake and the host never switches it underneath a
|
||||
// client whose output device is already open.
|
||||
h.client.audio_codec as f64,
|
||||
h.client.audio_sample_rate_hz as f64,
|
||||
h.client.audio_bits as f64,
|
||||
];
|
||||
let arr = env.new_double_array(buf.len())?;
|
||||
arr.set_region(env, 0, &buf)?;
|
||||
|
||||
@@ -31,6 +31,10 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
|
||||
Keychain-stored identity.
|
||||
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
|
||||
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
|
||||
- **Send logs to host** — the app keeps its recent log in a bounded in-memory ring (`ClientLog`, a
|
||||
drop-in for `os.Logger` that also writes the unified log); a host card's menu (or the gamepad
|
||||
UI's host options) posts it to the paired host's `/api/v1/client-logs`, where the web console's
|
||||
Logs page shows it next to the host's own — the same action the Gaming Mode console has.
|
||||
|
||||
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
|
||||
|
||||
|
||||
@@ -54,7 +54,20 @@ struct GamepadAddHostView: View {
|
||||
@State private var port = "9777"
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
@State private var editing: String? = Self.initialEditing
|
||||
/// Shot harness only: open with a field being edited (`PUNKTFUNK_SHOT_EDITING=address`),
|
||||
/// so the keyboard tray and the row seated above it can be rendered without a pad.
|
||||
private static var initialEditing: String? {
|
||||
#if DEBUG
|
||||
guard ScreenshotMode.isActive else { return nil }
|
||||
let field = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_EDITING"] ?? ""
|
||||
return field.isEmpty ? nil : field
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
/// The edited row's flight between its place in the list and its seat above the keyboard.
|
||||
@Namespace private var fieldFlight
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -67,6 +80,20 @@ struct GamepadAddHostView: View {
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
// While the tray edits this row, the row IS the one seated above the keyboard
|
||||
// (see `bottomTray`); its slot here stays empty and keeps the list's layout.
|
||||
.opacity(editing == row.id ? 0 : 1)
|
||||
// The flight's origin/destination: an invisible frame-provider that exists only
|
||||
// while the row is HERE. When editing starts it unmounts and the seated row is
|
||||
// inserted with the same id, so SwiftUI animates the seated row in FROM this
|
||||
// frame; when editing ends it returns and the seated row's removal flies back to
|
||||
// it. Exactly one matched view per id at any time — two live ones with the
|
||||
// source flag swapped sent the invisible list row flying instead.
|
||||
.overlay {
|
||||
if editing != row.id {
|
||||
Color.clear.matchedGeometryEffect(id: row.id, in: fieldFlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
@@ -131,12 +158,16 @@ struct GamepadAddHostView: View {
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
// Not while the keyboard tray is up: Esc is the tray's Done then (see
|
||||
// GamepadKeyboard), and a shortcut here would fire first and close the whole screen.
|
||||
if editing == nil {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
@@ -173,29 +204,43 @@ struct GamepadAddHostView: View {
|
||||
#else
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field: a touch user can retarget the tray by tapping
|
||||
// another field row, and the keyboard's input wiring captured the previous
|
||||
// binding on appear — new identity forces a rewire to the new field.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||
// this one cell stays a label.
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// The field being typed into sits HERE, directly above the keys — flown in from
|
||||
// its place in the list on the tray's spring — so what the keyboard covers no
|
||||
// longer depends on where the list happened to be scrolled. The same row view,
|
||||
// so it reads as the row itself having come down to the keyboard.
|
||||
if let row = rows.first(where: { $0.id == editing }) {
|
||||
rowView(row, focused: true)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
.matchedGeometryEffect(id: row.id, in: fieldFlight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.transition(.opacity)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field: a touch user can retarget the tray by tapping
|
||||
// another field row, and the keyboard's input wiring captured the previous
|
||||
// binding on appear — new identity forces a rewire to the new field.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||
// this one cell stays a label.
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
GamepadHintBar(hints: [
|
||||
.init(
|
||||
|
||||
@@ -40,6 +40,10 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// insets center exactly one at a time.
|
||||
let itemWidth: CGFloat
|
||||
let spacing: CGFloat
|
||||
/// The item to open ON when the strip mounts — a remembered position (the library's last
|
||||
/// opened title). Consulted once, before the first `reconcile()`; ignored when it isn't in
|
||||
/// `items`, in which case the strip opens on the first item as it always has.
|
||||
var initialItemID: Item.ID?
|
||||
/// A → activate the centered item.
|
||||
let onActivate: (Item) -> Void
|
||||
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
|
||||
@@ -57,6 +61,9 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var onUp: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// L1 (`false`) / R1 (`true`) → the screen's own shoulder action (the Collections screen steps
|
||||
/// its sort with them). Set, it takes the shoulders away from `shoulderJump`.
|
||||
var onShoulder: ((Bool) -> Void)?
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
@@ -172,9 +179,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
#if os(tvOS)
|
||||
// Land initial focus on the first card (the launcher's first host / the coverflow's
|
||||
// first title) instead of wherever the engine guesses.
|
||||
.defaultFocus($focusedID, items.first?.id)
|
||||
// Land initial focus on the remembered card when there is one, else the first
|
||||
// (the launcher's first host / the coverflow's first title) instead of wherever
|
||||
// the engine guesses.
|
||||
.defaultFocus(
|
||||
$focusedID,
|
||||
initialItemID.flatMap { index(of: $0) != nil ? $0 : nil } ?? items.first?.id)
|
||||
// Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection,
|
||||
// controller detent, and an imperative center scroll.
|
||||
.onChange(of: focusedID) { _, newValue in
|
||||
@@ -209,6 +219,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.onAppear {
|
||||
// Seat the cursor on the remembered item before the first reconcile publishes it as
|
||||
// the scroll target — only while nothing has been aligned yet (a re-appear keeps
|
||||
// wherever the strip already is).
|
||||
if scrolledID == nil, let id = initialItemID, let idx = index(of: id) {
|
||||
cursor = idx
|
||||
}
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
@@ -288,8 +304,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// landing first and its neighbours fanning outward to either side.
|
||||
private func entrance(_ idx: Int) -> CardEntrance {
|
||||
// Capped so a several-hundred-title library never queues a card behind a visibly long
|
||||
// wait — everything past the cap lands together, well off-screen anyway.
|
||||
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
|
||||
// wait — everything past the cap lands together, well off-screen anyway. The stagger and
|
||||
// the cap move as a PAIR: their ratio is how many steps actually fan, so a wider offset
|
||||
// under the same cap would land the outer half of the strip in one block.
|
||||
// Mirrors `entrances::CARDS` in the desktop console's anim.rs — nothing pins the two
|
||||
// together, so a change here is a change there.
|
||||
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.12)
|
||||
return CardEntrance(
|
||||
progress: entranceProgress,
|
||||
start: delay / CardEntrance.total,
|
||||
@@ -325,7 +345,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
input.onShoulder = onShoulder ?? (shoulderJump > 0 ? { shoulder(right: $0) } : nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -499,9 +519,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||
/// unstaggered cross-fade.
|
||||
struct CardEntrance: ViewModifier, Animatable {
|
||||
/// How long ONE card takes to travel, and the most any card waits before it starts.
|
||||
/// How long ONE card takes to travel, and the most any card waits before it starts. The
|
||||
/// pair matches `entrances::CARDS` in the desktop console's anim.rs; `maxDelay` divided by
|
||||
/// the per-step stagger in `entrance(_:)` is the number of cards that visibly fan, which is
|
||||
/// why it moves whenever that stagger does.
|
||||
static let perCard: Double = 0.6
|
||||
static let maxDelay: Double = 0.42
|
||||
static let maxDelay: Double = 0.6
|
||||
/// The master timeline the carousel animates 0 → 1.
|
||||
static var total: Double { perCard + maxDelay }
|
||||
|
||||
|
||||
@@ -162,14 +162,28 @@ struct GamepadHomeView: View {
|
||||
.geometryGroup()
|
||||
.zIndex(1)
|
||||
.id(screen.id)
|
||||
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
// Reduce Motion: a crossfade — no slide, no scale (the desktop's rule).
|
||||
.transition(
|
||||
reduceMotion
|
||||
? .opacity
|
||||
: .gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
}
|
||||
// Back mid-push (the desktop's interruptible transition): while a push is in flight
|
||||
// no layer owns the controller, so this zero-size listener takes B alone and turns
|
||||
// the entering screen around. A and the rest stay dropped until the spring has
|
||||
// passed 0.85 of its travel (`transitioning`), which keeps a double-tapped A from
|
||||
// pushing two screens.
|
||||
if transitioning, topScreen != nil {
|
||||
MidPushBackCatcher { backOutOfPush() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
|
||||
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
|
||||
// derived id catches every writer. Reduce Motion snaps.
|
||||
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
|
||||
// derived id catches every writer. Reduce Motion crossfades on the reduced spring.
|
||||
.animation(
|
||||
reduceMotion ? GamepadShellMotion.reducedScreen : GamepadShellMotion.screen,
|
||||
value: topScreenID)
|
||||
// ONE living field for every layer, still a `.background` (the layout rule in this
|
||||
// file's header). Its calm is CHASED between the launcher's aurora and the form
|
||||
// screens' quiet, never crossfaded per screen — the console's `bg_mix`.
|
||||
@@ -195,7 +209,9 @@ struct GamepadHomeView: View {
|
||||
transitionEpoch += 1
|
||||
let epoch = transitionEpoch
|
||||
transitioning = true
|
||||
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
|
||||
// The gate opens when the spring has passed 0.85 of its travel, not when it has
|
||||
// settled — the wall is gone, the double-tap protection stays.
|
||||
let hold = reduceMotion ? 0.05 : GamepadShellMotion.inputOpensAfter
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
|
||||
if epoch == transitionEpoch { transitioning = false }
|
||||
}
|
||||
@@ -348,6 +364,24 @@ struct GamepadHomeView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Back pressed while a push is still in flight: clear the trigger that raised the top
|
||||
/// screen, so the same spring carries it back down — the desktop retargets its NAV spring
|
||||
/// to 0 mid-push; SwiftUI does the equivalent when the identity flips back before the
|
||||
/// insertion has settled.
|
||||
private func backOutOfPush() {
|
||||
guard transitioning, let screen = topScreen else { return }
|
||||
switch screen {
|
||||
case .pair: pairingTarget = nil
|
||||
case .editHost: editTarget = nil
|
||||
case .hostOptions: hostOptionsTarget = nil
|
||||
case .settings: showSettings = false
|
||||
case .addHost: showAddHost = false
|
||||
case .library: libraryTarget = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var topScreenID: String? {
|
||||
#if os(iOS)
|
||||
topScreen?.id
|
||||
@@ -620,6 +654,7 @@ struct GamepadHomeView: View {
|
||||
guard let profile = target.profile else { return }
|
||||
store.setPinned(host.id, profileID: profile.id, pinned: false)
|
||||
},
|
||||
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
|
||||
close: { if !transitioning { hostOptionsTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
@@ -808,3 +843,23 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Zero-size controller listener for a push in flight — B alone. The same shape as LibraryView's
|
||||
/// `LibraryBackCatcher`; `GamepadMenuInput.needsSnapshot` swallows the still-held A that pushed
|
||||
/// the screen, so only a FRESH B mid-push counts. Unmounts the moment the gate opens.
|
||||
private struct MidPushBackCatcher: View {
|
||||
let onBack: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.onAppear {
|
||||
input.onBack = onBack
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -64,6 +64,9 @@ struct GamepadHostOptionsView: View {
|
||||
/// Delete the saved record outright.
|
||||
let onRemove: () -> Void
|
||||
let onUnpin: () -> Void
|
||||
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
|
||||
/// unpaired host — the upload rides the pairing, so there is nothing to offer before it.
|
||||
var onSendLogs: (() async -> (ok: Bool, message: String))?
|
||||
var close: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
@@ -81,13 +84,21 @@ struct GamepadHostOptionsView: View {
|
||||
/// strict as it is, and none at all to be looser.
|
||||
@State private var armed = false
|
||||
@State private var copied = false
|
||||
/// The send-logs row's own state: its label and the detail band report the outcome in place,
|
||||
/// the same way Copy link says "Copied" — this surface has no toast.
|
||||
@State private var sendLogs: SendLogsState = .idle
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum SendLogsState: Equatable {
|
||||
case idle, sending, done(ok: Bool, message: String)
|
||||
}
|
||||
|
||||
private enum Action: String {
|
||||
case wake
|
||||
case copyLink
|
||||
case edit
|
||||
case forgetPairing
|
||||
case sendLogs
|
||||
case remove
|
||||
case unpin
|
||||
case cancel
|
||||
@@ -195,6 +206,15 @@ struct GamepadHostOptionsView: View {
|
||||
}
|
||||
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
|
||||
if onSendLogs != nil {
|
||||
let label: String
|
||||
switch sendLogs {
|
||||
case .idle: label = "Send logs to host"
|
||||
case .sending: label = "Sending logs\u{2026}"
|
||||
case .done(let ok, _): label = ok ? "Logs sent" : "Couldn't send logs"
|
||||
}
|
||||
list.append(Row(action: .sendLogs, label: label, icon: "doc.text"))
|
||||
}
|
||||
// Only a paired host has a pairing to drop.
|
||||
if host.pinnedSHA256 != nil {
|
||||
list.append(Row(
|
||||
@@ -221,6 +241,9 @@ struct GamepadHostOptionsView: View {
|
||||
case .forgetPairing:
|
||||
return "Drop the stored fingerprint. The host stays saved and the next connect "
|
||||
+ "pairs again."
|
||||
case .sendLogs:
|
||||
if case .done(_, let message) = sendLogs { return message }
|
||||
return "Upload this device's recent log to the host, for its web console's Logs page."
|
||||
case .remove:
|
||||
return armed
|
||||
? "Press again to remove — this cannot be undone."
|
||||
@@ -263,6 +286,15 @@ struct GamepadHostOptionsView: View {
|
||||
case .forgetPairing:
|
||||
onForgetPairing()
|
||||
performClose()
|
||||
case .sendLogs:
|
||||
guard let onSendLogs, sendLogs != .sending else { return }
|
||||
withAnimation(.smooth(duration: 0.2)) { sendLogs = .sending }
|
||||
Task {
|
||||
let outcome = await onSendLogs()
|
||||
withAnimation(.smooth(duration: 0.2)) {
|
||||
sendLogs = .done(ok: outcome.ok, message: outcome.message)
|
||||
}
|
||||
}
|
||||
case .remove:
|
||||
guard armed else {
|
||||
withAnimation(.smooth(duration: 0.2)) { armed = true }
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//
|
||||
// Edits are applied to the binding live (the caller's field row shows every keystroke), so
|
||||
// closing the keyboard is always "done" — there is no separate cancel/commit step to get wrong.
|
||||
// Touch stays a fallback: every keycap is tappable.
|
||||
// Touch stays a fallback: every keycap is tappable. A HARDWARE keyboard (an iPad on a Magic
|
||||
// Keyboard, a Mac) types straight into the field while the tray is up — characters insert,
|
||||
// ⌫ deletes, arrows move the key cursor, Return and Esc are Done — with no system keyboard ever
|
||||
// raised, because there is no text field to raise it.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -26,6 +29,9 @@ struct GamepadKeyboard: View {
|
||||
@State private var cursor = GridPos(row: 1, col: 0) // opens on "q"
|
||||
@State private var pressTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
/// Hardware-keyboard focus: the tray takes it on appear so key presses land here (the row
|
||||
/// list behind it hands its own key navigation off while editing).
|
||||
@FocusState private var hardwareFocus: Bool
|
||||
#if os(iOS)
|
||||
/// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@@ -85,9 +91,15 @@ struct GamepadKeyboard: View {
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
// Hardware keys. No focus ring — the tray draws its own cursor.
|
||||
.focusable()
|
||||
.focusEffectDisabled()
|
||||
.focused($hardwareFocus)
|
||||
.onKeyPress(phases: .down) { hardwareKey($0) }
|
||||
.onAppear {
|
||||
wire()
|
||||
input.start()
|
||||
hardwareFocus = true
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
@@ -181,5 +193,55 @@ struct GamepadKeyboard: View {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
}
|
||||
|
||||
/// A hardware key while the tray is up. Return and Esc are both Done — the binding already
|
||||
/// holds the text, so there is nothing to cancel; ⌫ deletes; arrows drive the key cursor
|
||||
/// (Return does NOT type the highlighted key — a keyboard user types letters, not caps);
|
||||
/// anything with ⌘ (⌘Q, ⌘W…) is left to the system.
|
||||
private func hardwareKey(_ key: KeyPress) -> KeyPress.Result {
|
||||
if key.modifiers.contains(.command) { return .ignored }
|
||||
switch key.key {
|
||||
case .escape, .return:
|
||||
haptics.confirm()
|
||||
onDone()
|
||||
return .handled
|
||||
case .delete, .deleteForward:
|
||||
DispatchQueue.main.async { press(.backspace) }
|
||||
return .handled
|
||||
case .leftArrow: move(.left); return .handled
|
||||
case .rightArrow: move(.right); return .handled
|
||||
case .upArrow: move(.up); return .handled
|
||||
case .downArrow: move(.down); return .handled
|
||||
case .space:
|
||||
DispatchQueue.main.async { press(.space) }
|
||||
return .handled
|
||||
default:
|
||||
break
|
||||
}
|
||||
// ⌫ can also arrive as a bare control character rather than a named key.
|
||||
if key.characters == "\u{7F}" || key.characters == "\u{08}" {
|
||||
DispatchQueue.main.async { press(.backspace) }
|
||||
return .handled
|
||||
}
|
||||
// Printable characters — including ones the on-screen grid doesn't offer (a capital,
|
||||
// an umlaut): the on-screen set is deliberately small, the field's own `allowed` set is
|
||||
// the real rule.
|
||||
let typed = key.characters
|
||||
guard !typed.isEmpty,
|
||||
typed.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) })
|
||||
else { return .ignored }
|
||||
if let allowed, !typed.unicodeScalars.allSatisfy(allowed.contains) {
|
||||
refuse()
|
||||
return .handled
|
||||
}
|
||||
// Off the key-event dispatch: writing the binding synchronously here re-rendered the
|
||||
// screen mid-delivery and a fast burst of keystrokes lost every other one.
|
||||
DispatchQueue.main.async {
|
||||
text.append(typed)
|
||||
pressTick &+= 1
|
||||
haptics.move()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -26,15 +26,24 @@ struct GamepadLibraryScreen: View {
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
/// The collection the shelf is drilled into, for the title — `host · profile · collection`.
|
||||
@State private var collection: String?
|
||||
|
||||
private var title: String {
|
||||
let base = target.title(in: profiles)
|
||||
guard let collection else { return "\(base) — Library" }
|
||||
return "\(base) \u{b7} \(collection) — Library"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LibraryView(
|
||||
store: store, target: target, onLaunch: onLaunch,
|
||||
onClose: close, controllerActive: controllerActive)
|
||||
onClose: close, controllerActive: controllerActive,
|
||||
onCollectionChanged: { collection = $0 })
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
// Leading, like every gamepad heading — no close chrome, B is the exit (the
|
||||
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
|
||||
Text("\(target.title(in: profiles)) — Library")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -51,21 +51,31 @@ enum GamepadScreen: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The console shell's motion constants, mapped to SwiftUI. Source of truth:
|
||||
/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`).
|
||||
/// The console shell's motion, mapped to SwiftUI from the numbers `ConsoleMotion` pins against
|
||||
/// the shared vectors' `motion_spring` block (version 2). Source of truth:
|
||||
/// `crates/pf-console-ui/src/shell.rs` (the NAV spring) and `shell/render.rs` (the geometry).
|
||||
enum GamepadShellMotion {
|
||||
/// One transition, both layers — the console's `TRANSITION_S`.
|
||||
static let duration: TimeInterval = 0.26
|
||||
/// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points.
|
||||
static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration)
|
||||
/// One transition, both layers — the console's `springs::NAV`. A spring rather than the old
|
||||
/// 0.26 s ease-out-cubic, so a Back pressed mid-push turns the entering screen around instead
|
||||
/// of waiting for a tween to finish (`ConsoleMotion.interruptible`).
|
||||
static let screen = Animation.spring(
|
||||
response: ConsoleMotion.response, dampingFraction: ConsoleMotion.damping)
|
||||
/// Reduce Motion: a plain crossfade on the desktop's `REDUCED_NAV` — no slide, no scale.
|
||||
static let reducedScreen = Animation.spring(
|
||||
response: ConsoleMotion.reducedResponse, dampingFraction: ConsoleMotion.reducedDamping)
|
||||
/// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same
|
||||
/// ease-out at 0.30 s lands within a few percent of it and settles together with the screen.
|
||||
static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30)
|
||||
/// The push/pop travel — the console's `36 * k`, k-floored for a landscape phone.
|
||||
static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 }
|
||||
static func slide(compact: Bool) -> CGFloat {
|
||||
compact ? 27 : CGFloat(ConsoleMotion.pushSlideDp)
|
||||
}
|
||||
/// The incoming screen grows from this; the revealed launcher grows back from `underScale`.
|
||||
static let inScale: CGFloat = 0.985
|
||||
static let underScale: CGFloat = 0.96
|
||||
static let inScale = CGFloat(ConsoleMotion.enterScale)
|
||||
static let underScale = CGFloat(ConsoleMotion.exitScale)
|
||||
/// When a fresh push starts accepting input other than Back (the desktop's
|
||||
/// `NAV_INPUT_OPENS` 0.85 of the spring's travel, as a time).
|
||||
static let inputOpensAfter: TimeInterval = ConsoleMotion.inputOpensAfter
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
|
||||
@@ -45,6 +45,8 @@ struct HomeView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
/// The outcome of the last "Send Logs to Host" — drives its alert.
|
||||
@State private var sendLogsResult: (ok: Bool, message: String)?
|
||||
// How this device shows its own list. `.added` is the default because it is what the grid
|
||||
// did before it could sort at all — an update should not rearrange anyone's hosts.
|
||||
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
|
||||
@@ -194,6 +196,16 @@ struct HomeView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.alert(
|
||||
sendLogsResult?.ok == true ? "Logs Sent" : "Couldn't Send Logs",
|
||||
isPresented: Binding(
|
||||
get: { sendLogsResult != nil },
|
||||
set: { if !$0 { sendLogsResult = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(sendLogsResult?.message ?? "")
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
@@ -292,6 +304,8 @@ struct HomeView: View {
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: { wake(host) },
|
||||
onEdit: { editTarget = host },
|
||||
onSendLogs: host.pinnedSHA256 != nil
|
||||
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
|
||||
profileMenu: profileMenu(for: host),
|
||||
pinnedProfile: pinned)
|
||||
}
|
||||
|
||||
@@ -126,13 +126,20 @@ struct HostCardView: View {
|
||||
let onSpeedTest: () -> Void
|
||||
let onForget: () -> Void
|
||||
let onRemove: () -> Void
|
||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||
/// Open this host's game library — a MENU action. `nil` — no library affordance at all — when
|
||||
/// the setting is off or the host is unpaired (the library plane needs the pinned identity).
|
||||
///
|
||||
/// Never the card's primary tap: tapping a host connects to it, on every surface. Browsing is
|
||||
/// one step further in, exactly where the console shell keeps it (Y on a tile).
|
||||
var onBrowseLibrary: (() -> Void)? = nil
|
||||
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
||||
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||
var onWake: (() -> Void)? = nil
|
||||
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
||||
var onEdit: (() -> Void)? = nil
|
||||
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired —
|
||||
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
|
||||
var onSendLogs: (() -> Void)? = nil
|
||||
/// This card's profile affordances — nil on surfaces that don't offer them.
|
||||
var profileMenu: HostProfileMenu? = nil
|
||||
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
|
||||
@@ -248,6 +255,9 @@ struct HostCardView: View {
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if let onSendLogs {
|
||||
Button("Send Logs to Host", action: onSendLogs)
|
||||
}
|
||||
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
||||
Button("Wake Host", systemImage: "power", action: onWake)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user