Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4c9e44cc | |||
| ac60db5410 | |||
| 6b9f261dff | |||
| 75b3c94f60 | |||
| b8da32e8b6 | |||
| c4645a8938 | |||
| 252960291e | |||
| 01266ff18d | |||
| b50b698078 | |||
| 762a627df9 | |||
| a93f5a71ce | |||
| a7d4a93ff2 | |||
| 499bf2dae8 | |||
| ff38933312 | |||
| 56f9c8c4b4 | |||
| 5249d31dfa | |||
| 694bec4ead | |||
| e1d7fa2a30 | |||
| 7b9337c7d0 | |||
| 0325e1cf6f | |||
| e807ffbff8 | |||
| 6425edb8e4 | |||
| f24379c2f8 | |||
| a40ae49cf8 | |||
| 7f1680b043 | |||
| a959e731da | |||
| 81edd27155 | |||
| 4d2cc2a3a7 | |||
| d352e4e456 | |||
| 739a5f76bf | |||
| a70811043e | |||
| 9127c3465f | |||
| 2621b6e6b1 | |||
| 705a8baddf | |||
| 1fc9ef0050 | |||
| 12148243bd | |||
| 8dc5d672e2 | |||
| 719b1ef403 | |||
| eb8a659319 | |||
| fa4df1de9e | |||
| ef862454b0 | |||
| f77eec1299 | |||
| 575975687c | |||
| 49ba1cd11b | |||
| e71cb9b7bd | |||
| 9724fb4a4e | |||
| 767f028bdf | |||
| 4c3b11445c | |||
| 1b73361372 | |||
| d2b4e3d71c | |||
| 0bca67f73e | |||
| 9d67dc18aa | |||
| b349724fe9 | |||
| 32e5594a9a | |||
| f4f6c5556f | |||
| 5c7e0afa99 | |||
| 5a384fe788 | |||
| a2433d77cf | |||
| a87b279c2b | |||
| 9bf72cdfb5 | |||
| a1af916e38 | |||
| 46b7ffc001 | |||
| 9b7fc127ef | |||
| 1a559e8d5e | |||
| 160914c48b | |||
| ed0ce5dc6d | |||
| f2fa7828d6 | |||
| 85513d1697 | |||
| 0058f624a2 | |||
| a7a1e871e8 | |||
| 840e5d590e | |||
| d58524c899 | |||
| 6db91cbf40 | |||
| 60d4653083 | |||
| 927a571414 | |||
| f3b6ccaa7f | |||
| d8e8529cd7 | |||
| 4201851c7f | |||
| eb4bca11c5 | |||
| 69f30f30b6 | |||
| f7356d0820 | |||
| 51cdaea3f3 | |||
| ea2e3578e2 | |||
| 8d8168b0e0 | |||
| 61c752e91e | |||
| 8c854e0a19 | |||
| 70a74b0d7c | |||
| 41be73fbc6 | |||
| 1830e095f8 | |||
| 45bde370e2 | |||
| 57d89217fb | |||
| 650acda334 | |||
| 89aa52bc58 | |||
| 384fc30833 | |||
| 365d4bb8f1 | |||
| f1efd3091e | |||
| 446818eea6 | |||
| 4d6c2394dc | |||
| 2bea02b0ea | |||
| 528a51d75c | |||
| b597bb74bd | |||
| 49533ff90a | |||
| 1b890ae919 | |||
| f88d0ae4dc | |||
| 94802795e7 | |||
| 764b5d938b | |||
| 1af11cc64d | |||
| 2f214532d9 | |||
| 31bc863084 | |||
| 60af4de3ba | |||
| aedffc69dd | |||
| 26cac9ce20 | |||
| 48933dc405 | |||
| e5166c6e6e | |||
| 59fc820226 | |||
| d611645ffc | |||
| 17457cf4ba | |||
| 6263108e15 | |||
| 0c427cb3f1 | |||
| 5109a4c80a | |||
| 43e52437c0 | |||
| 2642ba6ad0 | |||
| 236c59754b | |||
| 68b9f108ab | |||
| 5cd66eca59 | |||
| a9dc6efe55 | |||
| 9822fc3b1c | |||
| cdb43f00fe | |||
| 644c035a34 | |||
| 3c16c1dd30 | |||
| c0fc2d8ee8 | |||
| ef5808254a | |||
| 8a18e130a2 | |||
| cd701a9594 | |||
| 05868ef634 | |||
| 2d37835545 | |||
| 9514a8c0e2 | |||
| 97c67b2692 | |||
| 0ad4e6eff7 | |||
| 76be4c3e12 | |||
| 84329205eb | |||
| e9d8f2bc04 | |||
| bbbb7f5723 | |||
| 5ab6daa694 | |||
| 76594f27c1 | |||
| e89b2f60eb | |||
| 63bc2bb10f | |||
| ad532b08a0 | |||
| 93093f3cf9 | |||
| fdda7144ed | |||
| 0dc414f197 | |||
| a95b518ef3 | |||
| f77fdee3e9 | |||
| a85be8e467 | |||
| 1dfcb0b2f6 | |||
| e87fd42cee | |||
| dd02e1f402 | |||
| 2271f67202 | |||
| 89aa6767f9 | |||
| 7cea893db5 | |||
| e55ff1bb28 | |||
| 890c7531d8 | |||
| e6fbcecdb9 | |||
| f01c5e210c | |||
| d294b3923c | |||
| 4c18bb80ca | |||
| 89ff326ebf | |||
| d0d9bd5bfb |
@@ -0,0 +1,18 @@
|
|||||||
|
# Workspace-wide build flags.
|
||||||
|
#
|
||||||
|
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
|
||||||
|
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
|
||||||
|
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
|
||||||
|
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
|
||||||
|
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
|
||||||
|
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
|
||||||
|
# extensions falls back safely.
|
||||||
|
#
|
||||||
|
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
|
||||||
|
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
|
||||||
|
# `--cfg aes_armv8` themselves.
|
||||||
|
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
|
||||||
|
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
|
||||||
|
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
|
||||||
|
[target.'cfg(target_arch = "aarch64")']
|
||||||
|
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||||
@@ -90,7 +90,14 @@ jobs:
|
|||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||||
# both client binaries must ship (build-client-deb.sh installs both).
|
# both client binaries must ship (build-client-deb.sh installs both).
|
||||||
cargo build --release --locked \
|
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||||
|
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||||
|
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||||
|
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||||
|
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||||
|
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||||
|
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||||
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
|||||||
|
|
||||||
## Before you push
|
## Before you push
|
||||||
|
|
||||||
|
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||||
|
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
|
||||||
|
on formatting alone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git config core.hooksPath scripts/git-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the usual full pass:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|||||||
Generated
+26
-28
@@ -870,15 +870,6 @@ dependencies = [
|
|||||||
"itertools 0.10.5",
|
"itertools 0.10.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-channel"
|
|
||||||
version = "0.5.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2154,7 +2145,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2286,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2765,9 +2756,10 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"ash",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2775,6 +2767,7 @@ dependencies = [
|
|||||||
"pf-ffvk",
|
"pf-ffvk",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"pyrowave-sys",
|
||||||
"rustls",
|
"rustls",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2787,7 +2780,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2808,7 +2801,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2817,7 +2810,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3001,7 +2994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3017,7 +3010,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3033,7 +3026,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3048,22 +3041,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"crossbeam-channel",
|
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"opus",
|
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"sdl3",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"wasapi",
|
|
||||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||||
"windows-reactor",
|
"windows-reactor",
|
||||||
"windows-reactor-setup",
|
"windows-reactor-setup",
|
||||||
@@ -3072,7 +3060,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3103,7 +3091,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3131,9 +3119,11 @@ dependencies = [
|
|||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
"opus",
|
"opus",
|
||||||
|
"parking_lot",
|
||||||
"pf-driver-proto",
|
"pf-driver-proto",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"pyrowave-sys",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -3175,7 +3165,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3189,7 +3179,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3204,6 +3194,14 @@ dependencies = [
|
|||||||
"winresource",
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyrowave-sys"
|
||||||
|
version = "0.12.0"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"cmake",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/pf-console-ui",
|
"crates/pf-console-ui",
|
||||||
"crates/pf-ffvk",
|
"crates/pf-ffvk",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
|
"crates/pyrowave-sys",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
"clients/session",
|
"clients/session",
|
||||||
@@ -35,7 +36,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.9.2"
|
version = "0.12.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+679
-297
File diff suppressed because it is too large
Load Diff
+27
-7
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.9.1"
|
"version": "0.11.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -2043,11 +2043,12 @@
|
|||||||
},
|
},
|
||||||
"ApiCodec": {
|
"ApiCodec": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Video codec identifier.",
|
"description": "Video codec identifier. The wire token matches the codec's canonical name used across the\nstack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.",
|
||||||
"enum": [
|
"enum": [
|
||||||
"h264",
|
"h264",
|
||||||
"h265",
|
"hevc",
|
||||||
"av1"
|
"av1",
|
||||||
|
"pyrowave"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ApiDisplayInfo": {
|
"ApiDisplayInfo": {
|
||||||
@@ -2811,6 +2812,7 @@
|
|||||||
"app_version",
|
"app_version",
|
||||||
"gfe_version",
|
"gfe_version",
|
||||||
"codecs",
|
"codecs",
|
||||||
|
"gamestream",
|
||||||
"ports"
|
"ports"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2831,6 +2833,10 @@
|
|||||||
},
|
},
|
||||||
"description": "Codecs the host can encode (NVENC)."
|
"description": "Codecs the host can encode (NVENC)."
|
||||||
},
|
},
|
||||||
|
"gamestream": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)."
|
||||||
|
},
|
||||||
"gfe_version": {
|
"gfe_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "GFE version advertised to Moonlight clients."
|
"description": "GFE version advertised to Moonlight clients."
|
||||||
@@ -3393,9 +3399,16 @@
|
|||||||
"video_streaming",
|
"video_streaming",
|
||||||
"audio_streaming",
|
"audio_streaming",
|
||||||
"pin_pending",
|
"pin_pending",
|
||||||
"paired_clients"
|
"paired_clients",
|
||||||
|
"active_sessions"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"active_sessions": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"audio_streaming": {
|
"audio_streaming": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True while the audio stream thread is running."
|
"description": "True while the audio stream thread is running."
|
||||||
@@ -3417,7 +3430,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionInfo",
|
"$ref": "#/components/schemas/SessionInfo",
|
||||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
"description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3428,7 +3441,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/StreamInfo",
|
"$ref": "#/components/schemas/StreamInfo",
|
||||||
"description": "The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)."
|
"description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3599,6 +3612,7 @@
|
|||||||
"armed",
|
"armed",
|
||||||
"sample_count",
|
"sample_count",
|
||||||
"started_unix_ms",
|
"started_unix_ms",
|
||||||
|
"elapsed_ms",
|
||||||
"kind"
|
"kind"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3606,6 +3620,12 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Capture currently running."
|
"description": "Capture currently running."
|
||||||
},
|
},
|
||||||
|
"elapsed_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Host-measured elapsed time of the in-progress capture, in ms (`0` if idle). Computed from the\nhost's MONOTONIC clock, so a console can show elapsed time without subtracting `started_unix_ms`\nfrom its own (possibly skewed) wall clock.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||||
|
|||||||
@@ -27,12 +27,27 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<!-- Gamepad rumble feedback. -->
|
<!-- Gamepad rumble feedback. -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
|
||||||
|
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
|
||||||
|
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
||||||
devices without a gamepad. -->
|
devices without a gamepad. -->
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||||
|
<!-- Neutralize Play's IMPLIED hard requirements, which filtered real TVs as "not compatible"
|
||||||
|
(reported on a Philips OLED707): RECORD_AUDIO implies android.hardware.microphone and the
|
||||||
|
Wi-Fi state permissions imply android.hardware.wifi, both required=true unless declared
|
||||||
|
otherwise. Some TVs declare no microphone (mic uplink is optional and runtime-gated) and
|
||||||
|
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||||
|
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
|
||||||
|
direct-BLE pad — both optional (the feature quietly disengages without them). -->
|
||||||
|
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
|
|
||||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||||
@@ -58,10 +73,16 @@
|
|||||||
android:name="android.game_mode_config"
|
android:name="android.game_mode_config"
|
||||||
android:resource="@xml/game_mode_config" />
|
android:resource="@xml/game_mode_config" />
|
||||||
|
|
||||||
|
<!-- configChanges includes `keyboard` (not just keyboardHidden): claiming a Steam
|
||||||
|
Controller 2's USB HID interface removes its lizard-mode keyboard/mouse input
|
||||||
|
devices, which flips CONFIG_KEYBOARD (QWERTY→NOKEYS) — without `keyboard` declared,
|
||||||
|
Android RECREATES the activity, disposing StreamScreen and killing the stream the
|
||||||
|
moment the capture engages (tester-diagnosed on-glass, 2026-07-15). Releasing the
|
||||||
|
interfaces at session end brings the devices back — same flip, same need. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|density|navigation"
|
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|screenLayout|density|navigation"
|
||||||
android:theme="@style/Theme.PunktfunkAndroid">
|
android:theme="@style/Theme.PunktfunkAndroid">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ internal fun PairPinDialog(
|
|||||||
if (fp.isNotEmpty()) {
|
if (fp.isNotEmpty()) {
|
||||||
onPaired(fp) // verified host fp — caller saves + connects
|
onPaired(fp) // verified host fp — caller saves + connects
|
||||||
} else {
|
} else {
|
||||||
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||||
|
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cause-specific user-facing messages for failed pair/connect attempts, keyed on the stable
|
||||||
|
* machine token from [NativeBridge.nativeTakeLastError]. One vocabulary for both the PIN
|
||||||
|
* ceremony and the request-access (delegated approval) path, so a dead network path is never
|
||||||
|
* reported as "wrong PIN" and an operator denial is never reported as a timeout — the exact
|
||||||
|
* collapse behind more than one support thread.
|
||||||
|
*/
|
||||||
|
object ConnectErrors {
|
||||||
|
/** Message for a failed SPAKE2 PIN ceremony ([NativeBridge.nativePair] returned `""`). */
|
||||||
|
fun pairMessage(token: String): String = when (token) {
|
||||||
|
"crypto" -> "Wrong PIN — check the PIN on the host's Pairing page and try again."
|
||||||
|
else -> shared(token) ?: transport(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message for a failed connect / request-access ([NativeBridge.nativeConnect] returned `0`).
|
||||||
|
* [requestAccess] tunes the fallback wording for the delegated-approval path.
|
||||||
|
*/
|
||||||
|
fun connectMessage(token: String, requestAccess: Boolean): String =
|
||||||
|
shared(token) ?: when (token) {
|
||||||
|
"crypto" ->
|
||||||
|
"The host's identity doesn't match the saved fingerprint — re-pair with this host."
|
||||||
|
"timeout", "io", "" ->
|
||||||
|
if (requestAccess) {
|
||||||
|
"The request never reached the host, or nobody approved it in time — " +
|
||||||
|
"check the network path (no VPN, no guest-Wi-Fi isolation) and the " +
|
||||||
|
"host's console."
|
||||||
|
} else {
|
||||||
|
transport(token)
|
||||||
|
}
|
||||||
|
else -> "Connection failed — check host/port and logcat."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host's typed rejection reasons — identical wording across every punktfunk client. */
|
||||||
|
private fun shared(token: String): String? = when (token) {
|
||||||
|
"not-armed" ->
|
||||||
|
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try again."
|
||||||
|
"bound-other" ->
|
||||||
|
"The host's pairing window is armed for a different device — arm it for this one."
|
||||||
|
"rate-limited" -> "Too many pairing attempts — wait a couple of seconds and try again."
|
||||||
|
"identity-required" ->
|
||||||
|
"The host requires pairing — pair this device (PIN or request access) first."
|
||||||
|
"denied" -> "The host declined this device's request."
|
||||||
|
"approval-timeout" ->
|
||||||
|
"Nobody approved the request on the host in time — approve this device in the " +
|
||||||
|
"host's console or web UI, then request access again."
|
||||||
|
"superseded" ->
|
||||||
|
"A newer request from this device replaced this one — approve the latest request " +
|
||||||
|
"on the host."
|
||||||
|
"wire-version" -> "Client and host versions don't match — update both to the same release."
|
||||||
|
"busy" -> "The host is busy with another session."
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transport-level causes (nothing typed arrived from the host). */
|
||||||
|
private fun transport(token: String): String = when (token) {
|
||||||
|
"timeout" ->
|
||||||
|
"The host didn't answer — check that this device and the host are on the same " +
|
||||||
|
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||||
|
"io" ->
|
||||||
|
"Couldn't reach the host — check that this device and the host are on the same " +
|
||||||
|
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||||
|
else -> "Pairing failed — the host didn't answer or closed the connection (see logcat)."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the
|
||||||
|
* live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested)
|
||||||
|
* statelessly.
|
||||||
|
*/
|
||||||
|
internal sealed interface ConnectPhase {
|
||||||
|
val hostName: String
|
||||||
|
|
||||||
|
/** The dial is in flight (shown the instant a host is picked). */
|
||||||
|
data class Connecting(override val hostName: String) : ConnectPhase
|
||||||
|
|
||||||
|
/** A sleeping host is being Wake-on-LAN'd and we're waiting for it to advertise again. */
|
||||||
|
data class Waking(override val hostName: String, val seconds: Int, val connectsAfter: Boolean) : ConnectPhase
|
||||||
|
|
||||||
|
/** The wake wait ran out — offer retry / cancel. */
|
||||||
|
data class WakeTimedOut(override val hostName: String) : ConnectPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-phase copy, shared by the console takeover and the touch modal so both read identically. */
|
||||||
|
private data class ConnectCopy(
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
/** Monospace the subtitle so a ticking seconds counter doesn't jitter its width. */
|
||||||
|
val monoSubtitle: Boolean,
|
||||||
|
val cancelLabel: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
|
||||||
|
is ConnectPhase.Connecting -> ConnectCopy(
|
||||||
|
"Connecting to ${phase.hostName}", "Establishing a secure connection…", false, "Cancel",
|
||||||
|
)
|
||||||
|
is ConnectPhase.Waking -> ConnectCopy(
|
||||||
|
"Waking ${phase.hostName}…",
|
||||||
|
"Waiting for it to come online · ${phase.seconds}s",
|
||||||
|
true,
|
||||||
|
// A wake-only wait (no dial after) says "Stop Waiting"; a wake that will connect says "Cancel".
|
||||||
|
if (!phase.connectsAfter) "Stop Waiting" else "Cancel",
|
||||||
|
)
|
||||||
|
is ConnectPhase.WakeTimedOut -> ConnectCopy(
|
||||||
|
"${phase.hostName} didn't wake",
|
||||||
|
"It may still be booting, or it's powered off / off this network.",
|
||||||
|
false,
|
||||||
|
"Cancel",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the
|
||||||
|
* user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||||
|
* out to be asleep:
|
||||||
|
*
|
||||||
|
* - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap,
|
||||||
|
* so a host that takes a beat to answer no longer looks like nothing happened.
|
||||||
|
* - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing
|
||||||
|
* Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout.
|
||||||
|
*
|
||||||
|
* Presentation is mode-aware (mirrors the Apple client): in the **console / gamepad** UI it's a
|
||||||
|
* full-screen aurora [ConnectTakeover] — the same signature backdrop the console home uses, driven by
|
||||||
|
* the pad (B cancels, A retries once timed out) with a hint bar. In the **default touch** UI it's a
|
||||||
|
* Material [ConnectModal] over the host grid, matching the app's other dialogs — the aurora takeover
|
||||||
|
* looked out of place there.
|
||||||
|
*
|
||||||
|
* The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` →
|
||||||
|
* `waker.start` → redial), so nothing blinks between them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConnectOverlay(
|
||||||
|
connectingHostName: String?,
|
||||||
|
waker: WakeController,
|
||||||
|
gamepadUi: Boolean,
|
||||||
|
onCancelConnect: () -> Unit,
|
||||||
|
) {
|
||||||
|
val waking = waker.waking
|
||||||
|
// Waking takes precedence (it only exists after a dial has failed) so a stray overlap can't strand
|
||||||
|
// the "Connecting…" phase over a wake in progress.
|
||||||
|
val phase = when {
|
||||||
|
waking != null && waking.timedOut -> ConnectPhase.WakeTimedOut(waking.hostName)
|
||||||
|
waking != null -> ConnectPhase.Waking(waking.hostName, waking.seconds, waking.connectsAfter)
|
||||||
|
connectingHostName != null -> ConnectPhase.Connecting(connectingHostName)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 default-UI presentation: a Material dialog over the host grid, matching the app's other touch
|
||||||
|
* dialogs. A spinner (or the sleep glyph once timed out) sits above the title; the scrim is inert so a
|
||||||
|
* stray tap can't drop a connect in flight — only the buttons or system Back cancel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectModal(
|
||||||
|
phase: ConnectPhase,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
val copy = connectCopy(phase)
|
||||||
|
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
properties = DialogProperties(dismissOnClickOutside = false),
|
||||||
|
icon = {
|
||||||
|
if (timedOut) {
|
||||||
|
Icon(Icons.Filled.Bedtime, contentDescription = null)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(copy.title, textAlign = TextAlign.Center) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
copy.subtitle,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// No confirm action until the wake times out; then "Try Again" is the primary button.
|
||||||
|
confirmButton = {
|
||||||
|
if (timedOut) TextButton(onClick = onRetry) { Text("Try Again") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onCancel) { Text(copy.cancelLabel) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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(
|
||||||
|
Modifier.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 = Color.White.copy(alpha = 0.9f),
|
||||||
|
modifier = Modifier.size(46.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PulsingSpinner()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
copy.title,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
copy.subtitle,
|
||||||
|
color = Color.White.copy(alpha = 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))
|
||||||
|
}
|
||||||
|
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
|
||||||
|
radius = maxR * (0.42f + p * 0.58f),
|
||||||
|
style = Stroke(width = 2.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
modifier = Modifier.size(54.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) {
|
|||||||
val cancelled = AtomicBoolean(false)
|
val cancelled = AtomicBoolean(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain dial in flight — [hostName] labels the unified [ConnectOverlay]'s "Connecting…" phase, and
|
||||||
|
* [cancelled] lets its Cancel abort. The native connect is a blocking call with no abort, so Cancel
|
||||||
|
* returns the UI immediately and a late-arriving handle is torn down silently rather than navigating
|
||||||
|
* into a session the user already backed out of. Mirrors [RequestAccessState]'s late-result handling.
|
||||||
|
*/
|
||||||
|
private class ConnectAttempt(val hostName: String) {
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(
|
fun ConnectScreen(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -107,6 +117,9 @@ fun ConnectScreen(
|
|||||||
var port by remember { mutableStateOf("9777") }
|
var port by remember { mutableStateOf("9777") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
var connecting by remember { mutableStateOf(false) }
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
|
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
|
||||||
|
// when idle or when the request-access / wake flows own the screen instead.
|
||||||
|
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
|
||||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||||
val (w, h, hz) = settings.effectiveMode(context)
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
|
|
||||||
@@ -267,11 +280,20 @@ fun ConnectScreen(
|
|||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val thisAttempt = ConnectAttempt(name)
|
||||||
|
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = null
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
|
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
|
||||||
|
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||||
|
if (thisAttempt.cancelled.get()) {
|
||||||
|
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
attempt = null
|
||||||
connecting = false
|
connecting = false
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||||
@@ -283,16 +305,32 @@ fun ConnectScreen(
|
|||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
discovery.start()
|
discovery.start()
|
||||||
if (onFailure != null) {
|
val token = NativeBridge.nativeTakeLastError()
|
||||||
status = ""
|
val unreachable = token == "timeout" || token == "io" || token.isEmpty()
|
||||||
|
if (onFailure != null && unreachable) {
|
||||||
|
// Unreachable — hand off to the wake-and-wait flow — clearing `attempt` above
|
||||||
|
// and setting `waker.waking` here land in one recompose, so the overlay slides
|
||||||
|
// Connecting → Waking without a blank frame.
|
||||||
onFailure()
|
onFailure()
|
||||||
} else {
|
} else {
|
||||||
status = "Connection failed — check host/port, PIN, and logcat"
|
// A typed host rejection (busy / versions differ / pairing required) means the
|
||||||
|
// host is awake — waking it would be nonsense; show the stated reason instead.
|
||||||
|
status = ConnectErrors.connectMessage(token, requestAccess = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel a plain dial in flight (the overlay's "Connecting…" phase, B / Cancel). The native
|
||||||
|
// connect can't be aborted, so flag this attempt (a late handle is closed silently in
|
||||||
|
// doConnectDirect) and return the UI now, resuming the discovery we paused for the dial.
|
||||||
|
fun cancelConnect() {
|
||||||
|
attempt?.cancelled?.set(true)
|
||||||
|
attempt = null
|
||||||
|
connecting = false
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
|
||||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||||
@@ -382,7 +420,12 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
status = "Request timed out — approve this device in the host's console, then retry."
|
// Cause-specific: an operator denial, an approval timeout, and a request that
|
||||||
|
// never reached the host are different problems with different fixes.
|
||||||
|
status = ConnectErrors.connectMessage(
|
||||||
|
NativeBridge.nativeTakeLastError(),
|
||||||
|
requestAccess = true,
|
||||||
|
)
|
||||||
discovery.start()
|
discovery.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,27 +549,9 @@ fun ConnectScreen(
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
status?.let {
|
status?.let {
|
||||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||||
// result/error (red). Previously every status showed in error-red, so a
|
// job now, so `status` only ever carries a result/error here — a filled error
|
||||||
// normal "Connecting…" looked like a failure.
|
// container reads as a real failure banner, not just red text lost in the layout.
|
||||||
if (connecting) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Result/error: a filled error container reads as a real failure banner,
|
|
||||||
// not just red text lost in the layout.
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
@@ -540,7 +565,6 @@ fun ConnectScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,8 +861,15 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||||
WakeOverlay(waker, gamepadUi)
|
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||||
|
// grid and the console home.
|
||||||
|
ConnectOverlay(
|
||||||
|
connectingHostName = attempt?.hostName,
|
||||||
|
waker = waker,
|
||||||
|
gamepadUi = gamepadUi,
|
||||||
|
onCancelConnect = { cancelConnect() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.hardware.input.InputManager
|
import android.hardware.input.InputManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CombinedVibration
|
import android.os.CombinedVibration
|
||||||
@@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import io.unom.punktfunk.kit.Sc2Capture
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,8 +149,38 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
|
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
|
||||||
|
// capture claims even those away), so it's enumerated on the capture side — USB device
|
||||||
|
// list + bonded BLE — and re-checked on USB hot-plug.
|
||||||
|
var sc2Generation by remember { mutableIntStateOf(0) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val receiver = object : android.content.BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||||
|
}
|
||||||
|
val filter = android.content.IntentFilter().apply {
|
||||||
|
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||||
|
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
|
context.registerReceiver(receiver, filter)
|
||||||
|
}
|
||||||
|
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||||
|
}
|
||||||
|
val sc2Probe = remember { Sc2Capture(context) }
|
||||||
|
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||||
|
val sc2Ble = remember(sc2Generation) {
|
||||||
|
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) sc2Probe.pairedBleAddress() else null
|
||||||
|
}
|
||||||
|
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||||
|
|
||||||
Group("Gamepads") {
|
Group("Gamepads") {
|
||||||
if (pads.isEmpty()) {
|
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||||
|
if (pads.isEmpty() && !sc2Present) {
|
||||||
Text(
|
Text(
|
||||||
"No controller detected. punktfunk can only forward devices Android " +
|
"No controller detected. punktfunk can only forward devices Android " +
|
||||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||||
@@ -158,8 +190,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
pads.forEachIndexed { i, dev ->
|
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||||
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
|
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||||
|
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||||
|
pads.forEach { dev ->
|
||||||
|
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +246,79 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
|
||||||
|
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
|
||||||
|
* these menus now; streamed as-is in a session), and a grant button when USB access is missing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settingOn = remember { SettingsStore(context).load().sc2Capture }
|
||||||
|
val active = activity?.sc2MenuActive == true
|
||||||
|
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||||
|
val permitted = usbDev != null && usbManager.hasPermission(usbDev)
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Steam Controller 2",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (active) {
|
||||||
|
Text(
|
||||||
|
"navigating this UI",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
usbDev == null -> "Paired via Bluetooth"
|
||||||
|
usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)"
|
||||||
|
else -> "Puck dongle (USB)"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
!settingOn -> Text(
|
||||||
|
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
|
||||||
|
"passthrough\" to capture it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
active -> Text(
|
||||||
|
"Captured — streams as-is: the host presents a real Steam Controller 2 " +
|
||||||
|
"that its Steam drives directly (trackpads, gyro, haptics).",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
usbDev != null && !permitted -> {
|
||||||
|
Text(
|
||||||
|
"Needs USB access to be captured.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) {
|
||||||
|
Text("Grant USB access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Text(
|
||||||
|
"Detected — capture engages automatically.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||||
@@ -222,8 +330,12 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
|||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
|
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||||
|
// user can tell which physical pad is which. The stream's wire pad index is
|
||||||
|
// assigned separately (lowest-free per device) once streaming starts.
|
||||||
|
val number = dev.controllerNumber
|
||||||
Text(
|
Text(
|
||||||
"forwarded to host",
|
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
@@ -319,6 +431,15 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device is actually forwarded to the host — the same rule the stream's [GamepadRouter]
|
||||||
|
* applies: a real, non-virtual controller whose source classes include GAMEPAD. A joystick-only node
|
||||||
|
* (e.g. a DualSense motion-sensor sibling, or an adapter that enumerates as bare joystick) shows in
|
||||||
|
* the list but isn't forwarded.
|
||||||
|
*/
|
||||||
|
private fun isForwarded(dev: InputDevice): Boolean =
|
||||||
|
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||||
|
|
||||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||||
if (Build.VERSION.SDK_INT >= 31) {
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
@@ -371,6 +492,10 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
|||||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||||
|
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
||||||
|
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
||||||
|
Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2"
|
||||||
|
Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck"
|
||||||
else -> "Automatic"
|
else -> "Automatic"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -351,7 +351,12 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
|||||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||||
}
|
}
|
||||||
pairing = false
|
pairing = false
|
||||||
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
if (fp.isNotEmpty()) {
|
||||||
|
onPaired(fp)
|
||||||
|
} else {
|
||||||
|
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||||
|
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? {
|
|||||||
if (s.hatY >= 0.5f) return NavDir.DOWN
|
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||||
if (s.hatX <= -0.5f) return NavDir.LEFT
|
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||||
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||||
return if (abs(s.stickY) >= abs(s.stickX)) {
|
// Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the
|
||||||
|
// vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the
|
||||||
|
// same on every client.
|
||||||
|
return if (abs(s.stickY) > abs(s.stickX)) {
|
||||||
when {
|
when {
|
||||||
s.stickY <= -STICK_HIGH -> NavDir.UP
|
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||||
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
|
|
||||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
@@ -82,7 +84,10 @@ fun GamepadSettingsScreen(
|
|||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
fun update(next: Settings) { s = next; onChange(next) }
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
val rows = buildSettingsRows(s, ::update)
|
val context = LocalContext.current
|
||||||
|
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||||
|
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||||
|
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
|
||||||
var focus by remember { mutableIntStateOf(0) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||||
@@ -257,8 +262,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
||||||
|
private fun buildSettingsRows(
|
||||||
|
s: Settings,
|
||||||
|
hasBodyVibrator: Boolean,
|
||||||
|
update: (Settings) -> Unit,
|
||||||
|
): List<GpRow> {
|
||||||
fun <T> choice(
|
fun <T> choice(
|
||||||
id: String, header: String?, label: String, detail: String,
|
id: String, header: String?, label: String, detail: String,
|
||||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
@@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
|||||||
"The virtual pad the host creates — Automatic matches this controller.",
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
) + listOfNotNull(
|
||||||
|
if (hasBodyVibrator) {
|
||||||
|
toggle(
|
||||||
|
"phoneRumble", null, "Rumble on this phone",
|
||||||
|
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||||
|
"for clip-on pads without rumble motors.",
|
||||||
|
s.rumbleOnPhone,
|
||||||
|
) { update(s.copy(rumbleOnPhone = it)) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
) + listOf(
|
||||||
choice(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.os.Looper
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean {
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberControllerConnected(): State<Boolean> {
|
fun rememberControllerConnected(): State<Boolean> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
// A menu-captured Steam Controller 2 counts as connected: it drives the console UI through
|
||||||
|
// the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse,
|
||||||
|
// and the claim removes even those) — the InputManager path below can't see it.
|
||||||
|
val activity = context as? MainActivity
|
||||||
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
@@ -59,5 +64,7 @@ fun rememberControllerConnected(): State<Boolean> {
|
|||||||
connected.value = Gamepad.firstPad() != null
|
connected.value = Gamepad.firstPad() != null
|
||||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||||
}
|
}
|
||||||
return connected
|
return remember {
|
||||||
|
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyCharacterMap
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -16,9 +24,13 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
|
||||||
|
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
||||||
|
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
/**
|
/**
|
||||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||||
@@ -27,8 +39,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
var streamHandle: Long = 0L
|
var streamHandle: Long = 0L
|
||||||
|
|
||||||
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
/**
|
||||||
var axisMapper: Gamepad.AxisMapper? = null
|
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
|
||||||
|
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
|
||||||
|
* hot-plug, and routes rumble/HID feedback back by pad index. Null while not streaming.
|
||||||
|
*/
|
||||||
|
var gamepadRouter: GamepadRouter? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||||
@@ -44,9 +60,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
var requestStreamExit: (() -> Unit)? = null
|
var requestStreamExit: (() -> Unit)? = null
|
||||||
|
|
||||||
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
|
||||||
private var heldPadButtons = 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||||
@@ -71,6 +84,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
private var highRefreshModeId = 0
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu-time Steam Controller 2 capture (UI mode — no router): a captured SC2 never produces
|
||||||
|
* ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this
|
||||||
|
* drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app
|
||||||
|
* is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture.
|
||||||
|
* [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the
|
||||||
|
* Controllers screen.
|
||||||
|
*/
|
||||||
|
private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null
|
||||||
|
var sc2MenuActive by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
private var sc2Receiver: BroadcastReceiver? = null
|
||||||
|
private var sc2PermissionAsked = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
|
||||||
|
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
|
||||||
|
* ViewRootImpl's focus-navigation stage — the one that grants initial focus for a real
|
||||||
|
* pad's first D-pad press — so on a phone in touch mode it lands on a focus-less window
|
||||||
|
* and does nothing (first on-glass run: only B worked, since it bypasses key events
|
||||||
|
* entirely). `FocusManager.moveFocus` is the public API for exactly this.
|
||||||
|
*/
|
||||||
|
private var sc2MoveFocus: ((androidx.compose.ui.focus.FocusDirection) -> Boolean)? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lastPadIsGamepad = !isTvDevice(this)
|
lastPadIsGamepad = !isTvDevice(this)
|
||||||
@@ -88,13 +125,166 @@ class MainActivity : ComponentActivity() {
|
|||||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||||
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||||
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||||
|
// SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture.
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||||
|
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||||
|
startSc2MenuNav()
|
||||||
|
}
|
||||||
|
SC2_MENU_PERMISSION -> {
|
||||||
|
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||||
|
startSc2MenuNav()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sc2Receiver = receiver
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||||
|
addAction(SC2_MENU_PERMISSION)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
|
registerReceiver(receiver, filter)
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
PunktfunkTheme {
|
PunktfunkTheme {
|
||||||
|
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
|
||||||
|
// the bootstrap: directional moves need an already-focused node, while one-
|
||||||
|
// dimensional traversal assigns initial focus when there is none.
|
||||||
|
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||||
|
sc2MoveFocus = { dir ->
|
||||||
|
focusManager.moveFocus(dir) ||
|
||||||
|
focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
|
||||||
|
}
|
||||||
|
onDispose { sc2MoveFocus = null }
|
||||||
|
}
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
startSc2MenuNav()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
// Release the claim while backgrounded so the OS (and other apps) get the pad back.
|
||||||
|
stopSc2MenuNav()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
sc2Receiver?.let { runCatching { unregisterReceiver(it) } }
|
||||||
|
sc2Receiver = null
|
||||||
|
stopSc2MenuNav()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
|
||||||
|
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
|
||||||
|
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
|
||||||
|
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
|
||||||
|
*/
|
||||||
|
fun startSc2MenuNav(forceAsk: Boolean = false) {
|
||||||
|
if (forceAsk) sc2PermissionAsked = false
|
||||||
|
if (streamHandle != 0L) return // StreamScreen owns the pad while streaming
|
||||||
|
if (sc2Menu?.isActive == true) return
|
||||||
|
if (!SettingsStore(this).load().sc2Capture) return
|
||||||
|
val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c ->
|
||||||
|
c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } }
|
||||||
|
c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } }
|
||||||
|
sc2Menu = c
|
||||||
|
}
|
||||||
|
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
val dev = cap.findUsbDevice()
|
||||||
|
when {
|
||||||
|
dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev)
|
||||||
|
dev != null && !sc2PermissionAsked -> {
|
||||||
|
sc2PermissionAsked = true
|
||||||
|
usbManager.requestPermission(
|
||||||
|
dev,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
this, 1,
|
||||||
|
Intent(SC2_MENU_PERMISSION).setPackage(packageName),
|
||||||
|
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||||
|
PendingIntent.FLAG_MUTABLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED -> {
|
||||||
|
cap.pairedBleAddress()?.let { cap.startBle(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */
|
||||||
|
fun stopSc2MenuNav() {
|
||||||
|
sc2Menu?.stop()
|
||||||
|
sc2MenuActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
|
||||||
|
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
|
||||||
|
* A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to
|
||||||
|
* the framework's focus navigation. Also claims the console-UI glyphs for the pad.
|
||||||
|
*/
|
||||||
|
private fun sc2NavKey(keyCode: Int, down: Boolean) {
|
||||||
|
if (streamHandle != 0L) return // raced a stream start — the wire path owns input now
|
||||||
|
lastPadIsGamepad = true
|
||||||
|
lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions
|
||||||
|
val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
|
||||||
|
// The console UI navigates through padKeyProbe (GamepadNavEffect's held-state + repeat
|
||||||
|
// machinery — A/X/Y/D-pad/Select), NOT the focus system: synthesized events must be
|
||||||
|
// offered there first, exactly like real ones in dispatchKeyEvent (tester-diagnosed:
|
||||||
|
// routing everything via super.dispatchKeyEvent bypassed the probe, so only B — which
|
||||||
|
// never rides key events — did anything). The probes gate on keycode only, so a
|
||||||
|
// synthetic KeyEvent satisfies them.
|
||||||
|
padKeyProbe?.let { if (it(KeyEvent(action, keyCode))) return }
|
||||||
|
when (keyCode) {
|
||||||
|
// B → back, on release (same edge the real-pad path uses).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed()
|
||||||
|
// A → activate the focused element (the focus system understands DPAD_CENTER; the
|
||||||
|
// Compose node focused via the moveFocus hook receives it once the ComposeView
|
||||||
|
// holds view-focus).
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A ->
|
||||||
|
super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||||
|
// D-pad → Compose's own focus API (a synthetic DPAD KeyEvent can't grant initial
|
||||||
|
// focus — see [sc2MoveFocus]); one move per press edge.
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Up)
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Down)
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Left)
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Right)
|
||||||
|
else -> super.dispatchKeyEvent(KeyEvent(action, keyCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveSc2Focus(dir: androidx.compose.ui.focus.FocusDirection) {
|
||||||
|
val hook = sc2MoveFocus
|
||||||
|
if (hook == null || !hook(dir)) {
|
||||||
|
// No composition hook (shouldn't happen) — fall back to the raw key dispatch.
|
||||||
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dirToKey(dir)))
|
||||||
|
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dirToKey(dir)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dirToKey(dir: androidx.compose.ui.focus.FocusDirection): Int = when (dir) {
|
||||||
|
androidx.compose.ui.focus.FocusDirection.Up -> KeyEvent.KEYCODE_DPAD_UP
|
||||||
|
androidx.compose.ui.focus.FocusDirection.Down -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||||
|
androidx.compose.ui.focus.FocusDirection.Left -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||||
|
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||||
private fun resolveHighRefreshMode() {
|
private fun resolveHighRefreshMode() {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -125,23 +315,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||||
val bit = Gamepad.buttonBit(event.keyCode)
|
val bit = Gamepad.buttonBit(event.keyCode)
|
||||||
if (bit != 0) {
|
if (bit != 0) {
|
||||||
when (event.action) {
|
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||||
KeyEvent.ACTION_DOWN -> {
|
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
||||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
||||||
heldPadButtons = heldPadButtons or bit
|
// can still leave — but an accidental brush of the four buttons no longer quits.
|
||||||
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
gamepadRouter?.onButton(event, bit)
|
||||||
// (a couch user has no keyboard/Back). Fired once per full chord.
|
|
||||||
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
|
||||||
heldPadButtons = 0
|
|
||||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyEvent.ACTION_UP -> {
|
|
||||||
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
|
||||||
heldPadButtons = heldPadButtons and bit.inv()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true // consumed
|
return true // consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +341,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||||
val vk = Keymap.toVk(event)
|
val vk = Keymap.toVk(event)
|
||||||
if (vk != 0) {
|
if (vk != 0) {
|
||||||
|
// Soft-keyboard events (the IME's virtual device — the stream's
|
||||||
|
// KeyCaptureView path) carry Shift only as META state, where a real
|
||||||
|
// keyboard sends discrete Shift transitions — so mirror the meta bit as
|
||||||
|
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
|
||||||
|
// host. Never applied to hardware events: their Shift already went over
|
||||||
|
// the wire, and a synthetic release here would un-hold a physical Shift
|
||||||
|
// the user is still pressing.
|
||||||
|
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
|
||||||
|
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
|
||||||
|
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
|
||||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||||
|
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
||||||
return true // consumed — don't let the system also act on it
|
return true // consumed — don't let the system also act on it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +393,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
if (streamHandle != 0L) {
|
if (streamHandle != 0L) {
|
||||||
if (axisMapper?.onMotion(event) == true) return true
|
if (gamepadRouter?.onMotion(event) == true) return true
|
||||||
return super.dispatchGenericMotionEvent(event)
|
return super.dispatchGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||||
@@ -248,9 +438,4 @@ class MainActivity : ComponentActivity() {
|
|||||||
-> true
|
-> true
|
||||||
else -> KeyEvent.isGamepadButton(kc)
|
else -> KeyEvent.isGamepadButton(kc)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
|
||||||
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ data class Settings(
|
|||||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||||
*/
|
*/
|
||||||
val autoWakeEnabled: Boolean = true,
|
val autoWakeEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||||
|
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
|
||||||
|
* the phone body is the only actuator in the player's hands. Off by default; read once per
|
||||||
|
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
|
||||||
|
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||||
|
*/
|
||||||
|
val rumbleOnPhone: Boolean = false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||||
|
* and pass it through AS-IS: the host presents a real `28DE:1302` that its Steam drives
|
||||||
|
* directly (Linux hosts). ON by default — it engages only when such a controller is actually
|
||||||
|
* present at stream start, so it costs nothing otherwise; the toggle exists for the rare
|
||||||
|
* setup where the OS-level pad (lizard mode) is preferred.
|
||||||
|
*/
|
||||||
|
val sc2Capture: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -142,6 +159,8 @@ class SettingsStore(context: Context) {
|
|||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
|
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||||
|
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -162,6 +181,8 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
|
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||||
|
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +218,8 @@ class SettingsStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||||
|
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||||
|
const val K_SC2_CAPTURE = "sc2_capture"
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
@@ -414,6 +415,26 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
subtitle = "What the app detects, with a live input test",
|
subtitle = "What the app detects, with a live input test",
|
||||||
onClick = onOpenControllers,
|
onClick = onOpenControllers,
|
||||||
)
|
)
|
||||||
|
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||||
|
if (hasBodyVibrator) {
|
||||||
|
ToggleRow(
|
||||||
|
title = "Rumble on this phone",
|
||||||
|
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
||||||
|
"motor — for clip-on pads without rumble motors",
|
||||||
|
checked = s.rumbleOnPhone,
|
||||||
|
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Steam Controller 2 passthrough",
|
||||||
|
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
|
||||||
|
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
|
||||||
|
"host drives it like the physical pad (trackpads, gyro, haptics)",
|
||||||
|
checked = s.sc2Capture,
|
||||||
|
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.text.InputType
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.BaseInputConnection
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.inputmethod.InputConnection
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -24,17 +39,21 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import io.unom.punktfunk.kit.Sc2Capture
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -149,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
}.onEach { it.setReferenceCounted(false) }
|
}.onEach { it.setReferenceCounted(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
|
||||||
|
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
||||||
|
var exitArming by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
DisposableEffect(handle) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
wifiLocks.forEach { lock ->
|
wifiLocks.forEach { lock ->
|
||||||
@@ -166,6 +189,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
|
// 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.
|
||||||
|
val priorSoftInput = window?.attributes?.softInputMode
|
||||||
|
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||||
|
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||||
@@ -174,21 +203,99 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
val priorOrientation = activity?.requestedOrientation
|
val priorOrientation = activity?.requestedOrientation
|
||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||||
|
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||||
|
// controller (Automatic). Built here, released on dispose.
|
||||||
|
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||||
|
activity?.gamepadRouter = router
|
||||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||||
|
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||||
|
// the same way the Back gesture does.
|
||||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||||
|
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||||
|
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||||
|
router.onExitArmed = { armed -> exitArming = armed }
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
|
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||||
|
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
|
||||||
|
val feedback = GamepadFeedback(
|
||||||
|
handle,
|
||||||
|
router,
|
||||||
|
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||||
|
).also { it.start() }
|
||||||
|
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||||
|
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||||
|
router.onSlotClosed = feedback::onDeviceRemoved
|
||||||
|
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||||
|
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||||
|
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||||
|
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
|
||||||
|
// actually present; the wire slot is claimed lazily on its first state report.
|
||||||
|
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
||||||
|
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
||||||
|
activity?.stopSc2MenuNav()
|
||||||
|
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
||||||
|
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||||
|
if (sc2 != null) {
|
||||||
|
feedback.onHidRaw = sc2::onHidRaw
|
||||||
|
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
val usbDev = sc2.findUsbDevice()
|
||||||
|
when {
|
||||||
|
usbDev != null && usbManager.hasPermission(usbDev) -> sc2.startUsb(usbDev)
|
||||||
|
usbDev != null -> {
|
||||||
|
// One-time system dialog; capture engages on grant (Android remembers the
|
||||||
|
// grant for as long as the device stays attached).
|
||||||
|
val action = "io.unom.punktfunk.SC2_USB_PERMISSION"
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != action) return
|
||||||
|
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||||
|
if (ok) sc2.startUsb(usbDev) else Log.i("punktfunk", "SC2 USB permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sc2UsbReceiver = receiver
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
usbManager.requestPermission(
|
||||||
|
usbDev,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context, 0,
|
||||||
|
Intent(action).setPackage(context.packageName),
|
||||||
|
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||||
|
PendingIntent.FLAG_MUTABLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED -> {
|
||||||
|
sc2.pairedBleAddress()?.let { addr ->
|
||||||
|
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
|
||||||
|
sc2.startBle(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
feedback.onHidRaw = null
|
||||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||||
activity?.axisMapper = null
|
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
|
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||||
|
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||||
|
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||||
|
activity?.gamepadRouter = null
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
activity?.requestStreamExit = null
|
activity?.requestStreamExit = null
|
||||||
|
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||||
|
activity?.startSc2MenuNav()
|
||||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
|
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||||
|
window?.setSoftInputMode(priorSoftInput)
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
@@ -209,6 +316,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
|
||||||
|
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||||
|
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -259,8 +369,22 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||||
|
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
|
||||||
|
// broken. Purely visual; it sits above the video and below the gesture layer.
|
||||||
|
if (exitArming) {
|
||||||
|
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||||
|
}
|
||||||
|
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||||
|
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.size(1.dp),
|
||||||
|
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||||
|
)
|
||||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||||
|
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||||
|
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
when (touchMode) {
|
when (touchMode) {
|
||||||
@@ -269,9 +393,63 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||||
|
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||||
|
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||||
|
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
|
||||||
|
* toggles its visibility.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExitChordHint(modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
"Hold to quit…",
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||||
|
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||||
|
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||||
|
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||||
|
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||||
|
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||||
|
* `MainActivity.dispatchKeyEvent`).
|
||||||
|
*/
|
||||||
|
private class KeyCaptureView(context: Context) : View(context) {
|
||||||
|
init {
|
||||||
|
isFocusable = true
|
||||||
|
isFocusableInTouchMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCheckIsTextEditor(): Boolean = true
|
||||||
|
|
||||||
|
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||||
|
outAttrs.inputType = InputType.TYPE_NULL
|
||||||
|
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||||
|
return BaseInputConnection(this, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setImeVisible(show: Boolean) {
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
|
?: return
|
||||||
|
if (show) {
|
||||||
|
requestFocus()
|
||||||
|
imm.showSoftInput(this, 0)
|
||||||
|
} else {
|
||||||
|
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ private const val TAP_SLOP = 12f
|
|||||||
private const val TAP_DRAG_MS = 250L
|
private const val TAP_DRAG_MS = 250L
|
||||||
private const val SCROLL_DIV = 4f
|
private const val SCROLL_DIV = 4f
|
||||||
|
|
||||||
|
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
|
||||||
|
// summon (up) / dismiss (down) the local soft keyboard.
|
||||||
|
private const val KB_SWIPE_FRACTION = 0.10f
|
||||||
|
|
||||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||||
@@ -40,7 +44,9 @@ private const val ACCEL_MAX = 3.0f
|
|||||||
*
|
*
|
||||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
|
||||||
|
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
|
||||||
|
* typing on the host).
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onCycleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
|
onKeyboard: (show: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
var scrolling = false
|
var scrolling = false
|
||||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||||
|
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
|
||||||
|
// scroll anchor) and a once-per-gesture latch.
|
||||||
|
var kbCount = 0
|
||||||
|
var kbAnchorX = 0f
|
||||||
|
var kbAnchorY = 0f
|
||||||
|
var kbFired = false
|
||||||
var prevCx = startX
|
var prevCx = startX
|
||||||
var prevCy = startY
|
var prevCy = startY
|
||||||
var upTime = down.uptimeMillis
|
var upTime = down.uptimeMillis
|
||||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||||
|
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
|
||||||
|
// bounce re-anchors instead of reading the count change as swipe travel.
|
||||||
|
if (pressed.size < 3) kbCount = 0
|
||||||
|
|
||||||
if (pressed.size >= 2) {
|
if (pressed.size == 2) {
|
||||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
prevCx = cx
|
prevCx = cx
|
||||||
moved = true
|
moved = true
|
||||||
}
|
}
|
||||||
|
} else if (pressed.size >= 3) {
|
||||||
|
// Three+ fingers → the keyboard swipe, never scroll (the documented
|
||||||
|
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
|
||||||
|
// accident of its old `>= 2` bound). Anchor the centroid per finger count
|
||||||
|
// (same reasoning as the scroll anchor above) and fire once per gesture when
|
||||||
|
// the vertical travel crosses the threshold: up = show, down = hide.
|
||||||
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
|
if (pressed.size != kbCount) {
|
||||||
|
kbCount = pressed.size
|
||||||
|
kbAnchorX = cx
|
||||||
|
kbAnchorY = cy
|
||||||
|
} else {
|
||||||
|
val dy = cy - kbAnchorY
|
||||||
|
// Real centroid travel disqualifies the tap classification below (else a
|
||||||
|
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||||
|
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
|
||||||
|
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
|
||||||
|
kbFired = true
|
||||||
|
onKeyboard(dy < 0) // finger up → show, finger down → hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
|
||||||
|
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
|
||||||
|
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
|
||||||
|
// down, so dropping straight back to one finger must re-anchor (zero delta),
|
||||||
|
// not replay the whole 3-finger phase as one cursor jump.
|
||||||
|
scrolling = false
|
||||||
|
scrollCount = 0
|
||||||
|
trackId = PointerId(Long.MIN_VALUE)
|
||||||
} else if (!scrolling) {
|
} else if (!scrolling) {
|
||||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||||
// back to one finger doesn't jerk the cursor).
|
// back to one finger doesn't jerk the cursor).
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
|||||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||||
*/
|
*/
|
||||||
class WakeController(private val scope: CoroutineScope) {
|
class WakeController(private val scope: CoroutineScope) {
|
||||||
/** null = idle; non-null drives [WakeOverlay]. */
|
/** null = idle; non-null drives the "Waking…" phase of [ConnectOverlay]. */
|
||||||
data class Waking(
|
data class Waking(
|
||||||
val hostName: String,
|
val hostName: String,
|
||||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package io.unom.punktfunk
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Bedtime
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
|
||||||
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
|
||||||
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
|
||||||
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
|
||||||
* timed out) while the touch buttons work for a pointer.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
|
||||||
val w = waker.waking ?: return
|
|
||||||
|
|
||||||
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
|
||||||
if (gamepadUi) {
|
|
||||||
// A retries once timed out; B falls through to the BackHandler above.
|
|
||||||
GamepadNavEffect2D(
|
|
||||||
active = true,
|
|
||||||
onDirection = {},
|
|
||||||
onActivate = { if (w.timedOut) waker.retry() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.6f))
|
|
||||||
// Swallow taps so the home behind can't be touched while waking.
|
|
||||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.padding(40.dp)
|
|
||||||
.widthIn(max = 380.dp)
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.background(Color(0xF01A1730))
|
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
|
||||||
.padding(28.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
if (w.timedOut) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Bedtime,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White.copy(alpha = 0.85f),
|
|
||||||
modifier = Modifier.size(34.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${w.hostName} didn't wake",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 19.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"It may still be booting, or it's powered off / off this network.",
|
|
||||||
color = Color.White.copy(alpha = 0.6f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
) {
|
|
||||||
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
|
||||||
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator(color = Color.White)
|
|
||||||
Text(
|
|
||||||
"Waking ${w.hostName}…",
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 19.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Waiting for it to come online · ${w.seconds}s",
|
|
||||||
color = Color.White.copy(alpha = 0.6f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
|
||||||
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,6 +68,29 @@ class ScreenshotTest {
|
|||||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||||
|
|
||||||
|
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||||
|
@Test
|
||||||
|
fun connecting() = shootScreen("connecting") {
|
||||||
|
HostsScene()
|
||||||
|
ConnectingScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun waking() = shootScreen("waking") {
|
||||||
|
HostsScene()
|
||||||
|
WakingScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wakeTimedOut() = shootScreen("wake-timed-out") {
|
||||||
|
HostsScene()
|
||||||
|
WakeTimedOutScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The console flow is the full-screen aurora takeover (a root capture).
|
||||||
|
@Test
|
||||||
|
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun trust() = shootScreen("trust") {
|
fun trust() = shootScreen("trust") {
|
||||||
HostsScene()
|
HostsScene()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.BrandDark
|
import io.unom.punktfunk.BrandDark
|
||||||
|
import io.unom.punktfunk.ConnectModal
|
||||||
|
import io.unom.punktfunk.ConnectPhase
|
||||||
|
import io.unom.punktfunk.ConnectTakeover
|
||||||
import io.unom.punktfunk.Settings
|
import io.unom.punktfunk.Settings
|
||||||
import io.unom.punktfunk.TouchMode
|
import io.unom.punktfunk.TouchMode
|
||||||
import io.unom.punktfunk.SettingsScreen
|
import io.unom.punktfunk.SettingsScreen
|
||||||
@@ -215,3 +218,31 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…"
|
||||||
|
* feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over
|
||||||
|
* the host grid, so the test composes [HostsScene] behind them and captures the whole screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectingScene() =
|
||||||
|
ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun WakingScene() =
|
||||||
|
ConnectModal(
|
||||||
|
ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true),
|
||||||
|
onCancel = {}, onRetry = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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 = {})
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ object Gamepad {
|
|||||||
const val BTN_X = 0x4000
|
const val BTN_X = 0x4000
|
||||||
const val BTN_Y = 0x8000
|
const val BTN_Y = 0x8000
|
||||||
|
|
||||||
|
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
|
||||||
|
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1–P4), touchpad click, and the misc/QAM button.
|
||||||
|
// Android's standard InputDevice path never produces these; the SC2 capture link does.
|
||||||
|
const val BTN_PADDLE1 = 0x10000
|
||||||
|
const val BTN_PADDLE2 = 0x20000
|
||||||
|
const val BTN_PADDLE3 = 0x40000
|
||||||
|
const val BTN_PADDLE4 = 0x80000
|
||||||
|
const val BTN_TOUCHPAD = 0x100000
|
||||||
|
const val BTN_MISC1 = 0x200000
|
||||||
|
|
||||||
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
||||||
const val AXIS_LS_X = 0
|
const val AXIS_LS_X = 0
|
||||||
const val AXIS_LS_Y = 1
|
const val AXIS_LS_Y = 1
|
||||||
@@ -52,6 +62,10 @@ object Gamepad {
|
|||||||
const val PREF_DUALSHOCK4 = 4
|
const val PREF_DUALSHOCK4 = 4
|
||||||
const val PREF_STEAMCONTROLLER = 5
|
const val PREF_STEAMCONTROLLER = 5
|
||||||
const val PREF_STEAMDECK = 6
|
const val PREF_STEAMDECK = 6
|
||||||
|
const val PREF_DUALSENSEEDGE = 7
|
||||||
|
const val PREF_SWITCHPRO = 8
|
||||||
|
const val PREF_STEAMCONTROLLER2 = 9
|
||||||
|
const val PREF_STEAMCONTROLLER2_PUCK = 10
|
||||||
|
|
||||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
@@ -59,10 +73,19 @@ object Gamepad {
|
|||||||
private const val VID_VALVE = 0x28DE
|
private const val VID_VALVE = 0x28DE
|
||||||
private const val VID_NINTENDO = 0x057E
|
private const val VID_NINTENDO = 0x057E
|
||||||
|
|
||||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
// Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
// host pad types — the Edge's back paddles get native slots on the virtual Edge (Android
|
||||||
|
// forwards no paddle input yet, but the identity + rich planes match the physical pad).
|
||||||
|
private val PID_DUALSENSE = setOf(0x0CE6)
|
||||||
|
private val PID_DUALSENSEEDGE = setOf(0x0DF2)
|
||||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||||
|
|
||||||
|
// Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct
|
||||||
|
// glyphs + positional layout). The Switch 2 Pro Controller (0x2069) and a Joy-Con 2 pair
|
||||||
|
// (0x2068) are the same full pad surface and ride the same virtual pad (SDL folds them to
|
||||||
|
// its NINTENDO_SWITCH_PRO type too).
|
||||||
|
private val PID_SWITCHPRO = setOf(0x2009, 0x2069, 0x2068)
|
||||||
|
|
||||||
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
||||||
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
||||||
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||||
@@ -70,6 +93,12 @@ object Gamepad {
|
|||||||
private val PID_STEAMDECK = setOf(0x1205)
|
private val PID_STEAMDECK = setOf(0x1205)
|
||||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||||
|
|
||||||
|
// Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305).
|
||||||
|
// Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded
|
||||||
|
// fallback. Keep Puck distinct so even that path requests the native multi-interface identity.
|
||||||
|
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303)
|
||||||
|
private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305)
|
||||||
|
|
||||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
// 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.
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
private val PID_XBOXONE = setOf(
|
private val PID_XBOXONE = setOf(
|
||||||
@@ -91,10 +120,15 @@ object Gamepad {
|
|||||||
val pid = dev.productId
|
val pid = dev.productId
|
||||||
return when {
|
return when {
|
||||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||||
|
vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE
|
||||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||||
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK ->
|
||||||
|
PREF_STEAMCONTROLLER2_PUCK
|
||||||
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2
|
||||||
|
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
||||||
else -> PREF_XBOX360
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,47 +205,26 @@ object Gamepad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**.
|
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
* instance per forwarded controller (owned by [GamepadRouter], which routes each device's events
|
||||||
* sticks on the host (which has no client-side held-state knowledge).
|
* to its own mapper so a second pad can't clobber the first); call [reset] on that slot closing
|
||||||
|
* (disconnect / session stop) so nothing sticks on the host (which has no client-side held-state
|
||||||
|
* knowledge).
|
||||||
*
|
*
|
||||||
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
|
* The router only ever feeds this a qualifying event from the mapper's own device — a real
|
||||||
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
|
* gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling
|
||||||
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
|
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||||
* sensors) and any second pad report every axis as 0, and folding them into the same state
|
* folds the event straight in without re-qualifying it.
|
||||||
* flapped a held trigger/stick between its value and 0 on every event interleave.
|
|
||||||
*/
|
*/
|
||||||
class AxisMapper(private val handle: Long) {
|
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||||
private var hatX = 0 // -1 / 0 / +1
|
private var hatX = 0 // -1 / 0 / +1
|
||||||
private var hatY = 0
|
private var hatY = 0
|
||||||
|
|
||||||
/** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */
|
/** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */
|
||||||
private var deviceId = -1
|
fun onMotion(event: MotionEvent) {
|
||||||
|
|
||||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
|
||||||
fun onMotion(event: MotionEvent): Boolean {
|
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
|
||||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
|
||||||
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
|
|
||||||
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
|
|
||||||
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
|
|
||||||
// joystick-class remotes don't — and those report every pad axis as 0 (see the
|
|
||||||
// class doc for the held-trigger flap this caused).
|
|
||||||
val dev = event.device ?: return false
|
|
||||||
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
|
|
||||||
// Single-pad model: pin to the first qualifying controller so a second pad (or its
|
|
||||||
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
|
|
||||||
if (deviceId != event.deviceId) {
|
|
||||||
if (deviceId != -1) {
|
|
||||||
if (InputDevice.getDevice(deviceId) != null) return false
|
|
||||||
reset() // the pinned pad is gone — lift its held state before adopting
|
|
||||||
}
|
|
||||||
deviceId = event.deviceId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
// 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_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||||
@@ -240,23 +253,39 @@ object Gamepad {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||||
|
// final getAxisValue would show the HAT already back at rest and miss the tap entirely.
|
||||||
|
// Feed every historical HAT sample (oldest→newest) through the same transition logic
|
||||||
|
// before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins:
|
||||||
|
// only the final value matters for an analog axis.)
|
||||||
|
for (h in 0 until event.historySize) {
|
||||||
|
applyHat(
|
||||||
|
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)),
|
||||||
|
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applyHat(
|
||||||
|
sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)),
|
||||||
|
sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */
|
||||||
|
private fun applyHat(hx: Int, hy: Int) {
|
||||||
if (hx != hatX) {
|
if (hx != hatX) {
|
||||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||||
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
||||||
hatX = hx
|
hatX = hx
|
||||||
}
|
}
|
||||||
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
|
|
||||||
if (hy != hatY) {
|
if (hy != hatY) {
|
||||||
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
||||||
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
||||||
hatY = hy
|
hatY = hy
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release-all: zero every axis and clear the held dpad. */
|
/** Release-all: zero every axis and clear the held dpad (all on this mapper's pad index). */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
for (id in 0..5) sendAxis(id, 0)
|
for (id in 0..5) sendAxis(id, 0)
|
||||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||||
@@ -268,10 +297,10 @@ object Gamepad {
|
|||||||
private fun sendAxis(id: Int, v: Int) {
|
private fun sendAxis(id: Int, v: Int) {
|
||||||
if (last[id] == v) return
|
if (last[id] == v) return
|
||||||
last[id] = v
|
last[id] = v
|
||||||
NativeBridge.nativeSendGamepadAxis(handle, id, v)
|
NativeBridge.nativeSendGamepadAxis(handle, id, v, pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down)
|
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)
|
||||||
|
|
||||||
// −1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
|
// −1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
|
||||||
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
|
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.unom.punktfunk.kit
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.lights.Light
|
import android.hardware.lights.Light
|
||||||
import android.hardware.lights.LightState
|
import android.hardware.lights.LightState
|
||||||
@@ -15,67 +16,95 @@ import android.view.InputDevice
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
* Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon
|
||||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
* poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed
|
||||||
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
* controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output
|
||||||
* player-LED via `LightsManager` (API 33+); adaptive
|
* → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are
|
||||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
* parse-validated and logged (Android has no public adaptive-trigger API).
|
||||||
|
*
|
||||||
|
* Each pull carries the wire pad index it is addressed to; [GamepadRouter.deviceForPad] resolves it
|
||||||
|
* to the physical controller currently holding that index — so a rumble the host aimed at pad 1
|
||||||
|
* drives pad 1's motors, and an update for an index with no live controller (a pad that just
|
||||||
|
* unplugged) is dropped. Per-controller rumble/light bindings are built lazily and cached by device
|
||||||
|
* id (bounded — at most 16 pads).
|
||||||
*
|
*
|
||||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||||
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
||||||
* this MUST run before `nativeClose` frees the session handle.
|
* this MUST run before the router is released and `nativeClose` frees the session handle.
|
||||||
*
|
*
|
||||||
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
|
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||||
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
|
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||||
* `Log.i` receipt lines fire regardless of rendering hardware.
|
*
|
||||||
|
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
|
||||||
|
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
|
||||||
|
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
|
||||||
|
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
|
||||||
|
* setting is on (see [deviceBodyVibrator]).
|
||||||
*/
|
*/
|
||||||
class GamepadFeedback(private val handle: Long) {
|
class GamepadFeedback(
|
||||||
|
private val handle: Long,
|
||||||
|
private val router: GamepadRouter?,
|
||||||
|
private val deviceVibrator: Vibrator? = null,
|
||||||
|
) {
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "pf.feedback"
|
const val TAG = "pf.feedback"
|
||||||
const val TAG_LED: Byte = 0x01
|
const val TAG_LED: Byte = 0x01
|
||||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||||
const val TAG_TRIGGER: Byte = 0x03
|
const val TAG_TRIGGER: Byte = 0x03
|
||||||
|
const val TAG_HID_RAW: Byte = 0x05
|
||||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||||
// A new host renews far below this, so it never actually holds this long there.
|
// A new host renews far below this, so it never actually holds this long there.
|
||||||
const val LEGACY_RUMBLE_MS = 60_000L
|
const val LEGACY_RUMBLE_MS = 60_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||||
|
private class RumbleBind(
|
||||||
|
val vm: VibratorManager?,
|
||||||
|
val legacy: Vibrator?,
|
||||||
|
val ids: IntArray,
|
||||||
|
val amplitudeControlled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One controller's lights binding (API 33+): its open session + the RGB / player-id lights it exposes. */
|
||||||
|
private class LightBind(
|
||||||
|
val session: LightsManager.LightsSession,
|
||||||
|
val rgb: Light?,
|
||||||
|
val player: Light?,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private var rumbleThread: Thread? = null
|
private var rumbleThread: Thread? = null
|
||||||
private var hidoutThread: Thread? = null
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
private var vm: VibratorManager? = null
|
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
|
||||||
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
// thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
|
||||||
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
// from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
|
||||||
private var legacy: Vibrator? = null
|
// the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain
|
||||||
private var vibratorIds: IntArray = IntArray(0)
|
// HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the
|
||||||
private var amplitudeControlled = false
|
// null value that caches "this controller has no vibrator / no controllable lights"). The lock
|
||||||
|
// guards only the map ops — rendering runs on the returned reference outside it; a stale reference
|
||||||
private var lightsSession: LightsManager.LightsSession? = null
|
// is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd
|
||||||
private var rgbLight: Light? = null
|
// no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed.
|
||||||
private var playerLight: Light? = null
|
private val bindsLock = Any()
|
||||||
|
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
||||||
|
private val lightBinds = HashMap<Int, LightBind?>()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
val dev = resolvePad()
|
|
||||||
bindRumble(dev)
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
bindLights(dev)
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
|
|
||||||
}
|
|
||||||
|
|
||||||
running = true
|
running = true
|
||||||
rumbleThread = Thread({
|
rumbleThread = Thread({
|
||||||
while (running) {
|
while (running) {
|
||||||
val ev = NativeBridge.nativeNextRumble(handle)
|
val ev = NativeBridge.nativeNextRumble(handle)
|
||||||
if (ev < 0L) continue // timeout / closed
|
if (ev < 0L) continue // timeout / closed
|
||||||
// ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The
|
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
||||||
// lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no
|
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
||||||
// in-band sentinel. No lease (legacy host) → the prior long one-shot.
|
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
||||||
|
// long one-shot.
|
||||||
|
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
||||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
||||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
||||||
renderRumble(
|
renderRumble(
|
||||||
|
pad,
|
||||||
((ev ushr 16) and 0xFFFF).toInt(),
|
((ev ushr 16) and 0xFFFF).toInt(),
|
||||||
(ev and 0xFFFF).toInt(),
|
(ev and 0xFFFF).toInt(),
|
||||||
durationMs,
|
durationMs,
|
||||||
@@ -84,7 +113,8 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
hidoutThread = Thread({
|
hidoutThread = Thread({
|
||||||
val buf = ByteBuffer.allocateDirect(64)
|
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||||
|
val buf = ByteBuffer.allocateDirect(128)
|
||||||
while (running) {
|
while (running) {
|
||||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||||
if (n < 0) continue // timeout / closed
|
if (n < 0) continue // timeout / closed
|
||||||
@@ -93,100 +123,136 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
rumbleThread?.interrupt()
|
rumbleThread?.interrupt()
|
||||||
hidoutThread?.interrupt()
|
hidoutThread?.interrupt()
|
||||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
|
||||||
runCatching { legacy?.cancel() }
|
|
||||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
// pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before
|
||||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
// StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A
|
||||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
// *bounded* join that times out would let a thread survive into the freed handle → use-after-
|
||||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
// free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard
|
||||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
// can't cover). Safe to block unbounded: the native pulls are internally time-bounded
|
||||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
// (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread
|
||||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
// observes running=false and exits within ~one timeout — the join returns promptly.
|
||||||
runCatching { rumbleThread?.join() }
|
runCatching { rumbleThread?.join() }
|
||||||
runCatching { hidoutThread?.join() }
|
runCatching { hidoutThread?.join() }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
runCatching { lightsSession?.close() }
|
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
||||||
lightsSession = null
|
// lights session.
|
||||||
rgbLight = null
|
runCatching { deviceVibrator?.cancel() }
|
||||||
playerLight = null
|
synchronized(bindsLock) {
|
||||||
vm = null
|
for (b in rumbleBinds.values) b?.let {
|
||||||
legacy = null
|
runCatching { it.vm?.cancel() }
|
||||||
vibratorIds = IntArray(0)
|
runCatching { it.legacy?.cancel() }
|
||||||
|
}
|
||||||
|
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||||
|
rumbleBinds.clear()
|
||||||
|
lightBinds.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
/**
|
||||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
* Evict and release the bindings for a controller that just disconnected — invoked from
|
||||||
|
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
|
||||||
|
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
|
||||||
|
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
|
||||||
|
* The next feedback for that pad index rebinds against whatever controller now holds it.
|
||||||
|
*/
|
||||||
|
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
|
||||||
|
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
|
||||||
|
@Suppress("MissingPermission", "NewApi")
|
||||||
|
fun onDeviceRemoved(deviceId: Int) {
|
||||||
|
synchronized(bindsLock) {
|
||||||
|
rumbleBinds.remove(deviceId)?.let {
|
||||||
|
runCatching { it.vm?.cancel() }
|
||||||
|
runCatching { it.legacy?.cancel() }
|
||||||
|
}
|
||||||
|
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Rumble ----
|
// ---- Rumble ----
|
||||||
|
|
||||||
private fun bindRumble(dev: InputDevice?) {
|
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||||
if (dev == null) {
|
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
val dev = router?.deviceForPad(pad) ?: return null
|
||||||
return
|
synchronized(bindsLock) {
|
||||||
|
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||||
|
val bind = bindRumble(dev)
|
||||||
|
rumbleBinds[dev.id] = bind
|
||||||
|
return bind
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||||
if (Build.VERSION.SDK_INT >= 31) {
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
val m = dev.vibratorManager
|
val m = dev.vibratorManager
|
||||||
val ids = m.vibratorIds
|
val ids = m.vibratorIds
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||||
return
|
return null
|
||||||
|
}
|
||||||
|
val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||||
|
Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp")
|
||||||
|
return RumbleBind(m, null, ids, amp)
|
||||||
}
|
}
|
||||||
vm = m
|
|
||||||
vibratorIds = ids
|
|
||||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
|
||||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
|
||||||
} else {
|
|
||||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val v = dev.vibrator
|
val v = dev.vibrator
|
||||||
if (!v.hasVibrator()) {
|
if (!v.hasVibrator()) {
|
||||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||||
return
|
return null
|
||||||
}
|
|
||||||
legacy = v
|
|
||||||
amplitudeControlled = v.hasAmplitudeControl()
|
|
||||||
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "rumble: bound legacy vibrator for '${dev.name}' amplitudeControl=${v.hasAmplitudeControl()}")
|
||||||
|
return RumbleBind(null, v, IntArray(0), v.hasAmplitudeControl())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
|
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||||
* `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the
|
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
||||||
* host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed
|
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
||||||
* 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration).
|
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
||||||
*/
|
*/
|
||||||
private fun renderRumble(low: Int, high: Int, durationMs: Long) {
|
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||||
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
|
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||||
|
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||||
|
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||||
|
// already decided the bind, and the user opted in.
|
||||||
|
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||||
|
val bind = rumbleBindFor(pad) ?: return
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
val m = vm
|
val m = bind.vm
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
if (lo == 0 && hi == 0) {
|
if (lo == 0 && hi == 0) {
|
||||||
m.cancel() // (0,0) = stop
|
m.cancel() // (0,0) = stop
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val combo = CombinedVibration.startParallel()
|
val combo = CombinedVibration.startParallel()
|
||||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
if (bind.amplitudeControlled && bind.ids.size >= 2) {
|
||||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
// Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left
|
||||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
|
// (XInput/Moonlight convention). Android does not guarantee the order of
|
||||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
|
// VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would
|
||||||
|
// invert the feel: the stronger amplitude drives the physically-lighter motor.
|
||||||
|
// Failure mode is tactile only — both motors still fire, nothing silences or
|
||||||
|
// crashes — so this stays the default pending per-pad on-glass verification (G20).
|
||||||
|
// ids beyond the first two (rare) are left alone here.
|
||||||
|
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
|
||||||
|
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
|
||||||
} else {
|
} else {
|
||||||
// Single motor or no amplitude control: blend both into one effect.
|
// Single motor or no amplitude control: blend both into one effect.
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs))
|
for (id in bind.ids) combo.addVibrator(id, oneShot(a, durationMs))
|
||||||
}
|
}
|
||||||
runCatching { m.vibrate(combo.combine()) }
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||||
val lv = legacy ?: return
|
val lv = bind.legacy ?: return
|
||||||
if (lo == 0 && hi == 0) {
|
if (lo == 0 && hi == 0) {
|
||||||
lv.cancel() // (0,0) = stop
|
lv.cancel() // (0,0) = stop
|
||||||
return
|
return
|
||||||
@@ -194,7 +260,30 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
runCatching {
|
runCatching {
|
||||||
lv.vibrate(
|
lv.vibrate(
|
||||||
if (amplitudeControlled) oneShot(a, durationMs)
|
if (bind.amplitudeControlled) oneShot(a, durationMs)
|
||||||
|
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
|
||||||
|
* one physical actuator, so both wire motors blend into one effect (the same blend as the
|
||||||
|
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
|
||||||
|
* TTL, cancel on (0,0).
|
||||||
|
*/
|
||||||
|
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||||
|
val v = deviceVibrator ?: return
|
||||||
|
val lo = toAmplitude(low)
|
||||||
|
val hi = toAmplitude(high)
|
||||||
|
if (lo == 0 && hi == 0) {
|
||||||
|
runCatching { v.cancel() } // (0,0) = stop
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
|
runCatching {
|
||||||
|
v.vibrate(
|
||||||
|
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
|
||||||
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -207,42 +296,69 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||||
// self-terminates on a lost stop; cancel on zero.
|
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||||
|
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||||
|
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
|
||||||
|
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
|
||||||
|
// so an uncaught throw here would kill the whole rumble poll thread.
|
||||||
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||||
VibrationEffect.createOneShot(durationMs, amp)
|
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
|
||||||
|
|
||||||
// ---- HID output ----
|
// ---- HID output ----
|
||||||
|
|
||||||
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
||||||
buf.rewind()
|
buf.rewind()
|
||||||
|
val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to
|
||||||
when (buf.get()) { // kind tag
|
when (buf.get()) { // kind tag
|
||||||
TAG_LED -> {
|
TAG_LED -> {
|
||||||
val r = buf.get().toInt() and 0xFF
|
val r = buf.get().toInt() and 0xFF
|
||||||
val g = buf.get().toInt() and 0xFF
|
val g = buf.get().toInt() and 0xFF
|
||||||
val b = buf.get().toInt() and 0xFF
|
val b = buf.get().toInt() and 0xFF
|
||||||
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
|
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
|
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||||
}
|
}
|
||||||
TAG_PLAYER_LEDS -> {
|
TAG_PLAYER_LEDS -> {
|
||||||
val bits = buf.get().toInt() and 0x1F
|
val bits = buf.get().toInt() and 0x1F
|
||||||
val player = playerIndexForBits(bits)
|
val player = playerIndexForBits(bits)
|
||||||
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
|
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
|
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||||
}
|
}
|
||||||
TAG_TRIGGER -> {
|
TAG_TRIGGER -> {
|
||||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||||
val effLen = n - 2
|
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
TAG_HID_RAW -> {
|
||||||
|
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||||
|
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
|
||||||
|
// for verbatim replay on the physical controller; dropped when no link owns the pad.
|
||||||
|
val kind = buf.get().toInt() and 0xFF
|
||||||
|
val len = n - 3
|
||||||
|
if (len > 0) {
|
||||||
|
val data = ByteArray(len)
|
||||||
|
buf.get(data)
|
||||||
|
onHidRaw?.invoke(pad, kind, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
|
||||||
|
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
|
||||||
|
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
|
||||||
|
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
|
||||||
|
* physical controller.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
|
||||||
|
|
||||||
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||||
0b00000 -> 0
|
0b00000 -> 0
|
||||||
@@ -253,37 +369,63 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindLights(dev: InputDevice?) {
|
/** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */
|
||||||
if (dev == null) {
|
private fun lightBindFor(pad: Int): LightBind? {
|
||||||
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
|
if (Build.VERSION.SDK_INT < 33) return null
|
||||||
return
|
val dev = router?.deviceForPad(pad) ?: return null
|
||||||
|
synchronized(bindsLock) {
|
||||||
|
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||||
|
val bind = bindLights(dev)
|
||||||
|
lightBinds[dev.id] = bind
|
||||||
|
return bind
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindLights(dev: InputDevice): LightBind? {
|
||||||
val lm = dev.lightsManager
|
val lm = dev.lightsManager
|
||||||
|
var rgb: Light? = null
|
||||||
|
var player: Light? = null
|
||||||
for (l in lm.lights) {
|
for (l in lm.lights) {
|
||||||
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
|
if (rgb == null && l.hasRgbControl()) rgb = l
|
||||||
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
|
if (player == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) player = l
|
||||||
}
|
}
|
||||||
if (rgbLight == null && playerLight == null) {
|
if (rgb == null && player == null) {
|
||||||
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
lightsSession = lm.openSession()
|
val session = lm.openSession()
|
||||||
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
|
Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'")
|
||||||
|
return LightBind(session, rgb, player)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setLightbar(argb: Int) {
|
private fun setLightbar(pad: Int, argb: Int) {
|
||||||
val s = lightsSession ?: return
|
val bind = lightBindFor(pad) ?: return
|
||||||
val l = rgbLight ?: return
|
val l = bind.rgb ?: return
|
||||||
runCatching {
|
runCatching {
|
||||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPlayerId(player: Int) {
|
private fun setPlayerId(pad: Int, player: Int) {
|
||||||
val s = lightsSession ?: return
|
val bind = lightBindFor(pad) ?: return
|
||||||
val l = playerLight ?: return
|
val l = bind.player ?: return
|
||||||
runCatching {
|
runCatching {
|
||||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This device's own body vibrator (the phone, not a controller), or null where there is none
|
||||||
|
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
|
||||||
|
* [GamepadFeedback.deviceVibrator] when it's on.
|
||||||
|
*/
|
||||||
|
fun deviceBodyVibrator(context: Context): Vibrator? {
|
||||||
|
val v = if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
return v?.takeIf { it.hasVibrator() }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-controller router for one stream session — the Android analogue of the Linux client's gamepad
|
||||||
|
* `Worker`/`Slot` model (`pf-client-core/src/gamepad.rs`) over the shared native-plane wire contract
|
||||||
|
* (`punktfunk-core/src/input.rs`). Each physical controller (Android `deviceId`) gets a STABLE
|
||||||
|
* lowest-free wire pad index (0..15) held for its lifetime and freed only on disconnect, so a pad
|
||||||
|
* dropping never renumbers the others (a game must not see its players shuffle). Every forwarded event
|
||||||
|
* carries that pad index; a [NativeBridge.nativeSendGamepadArrival] declaring the pad's type is sent
|
||||||
|
* once BEFORE its first input, a [NativeBridge.nativeSendGamepadRemove] on disconnect. Per-device axis
|
||||||
|
* state lives in each slot's [Gamepad.AxisMapper] so a second controller can't clobber the first.
|
||||||
|
* Feedback (rumble / HID) is routed BACK to the originating device by pad index via [deviceForPad].
|
||||||
|
*
|
||||||
|
* Selection: forward EVERY real controller (the Linux client's single-player pin has no Android UI
|
||||||
|
* surface yet — Automatic is the only mode). Lifetime matches the session: constructed on stream
|
||||||
|
* attach (opening a slot for every already-connected pad, so its Arrival lands before any input),
|
||||||
|
* released on detach.
|
||||||
|
*
|
||||||
|
* A single controller lands on wire index 0, so its per-transition button/axis wire is byte-identical
|
||||||
|
* to the old single-pad path (plus the Arrival/Remove declarations the contract requires — which an
|
||||||
|
* older host simply ignores).
|
||||||
|
*
|
||||||
|
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||||
|
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||||
|
* threads, so the slot table is a [ConcurrentHashMap].
|
||||||
|
*/
|
||||||
|
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||||
|
|
||||||
|
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||||
|
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||||
|
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||||
|
var held = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||||
|
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||||
|
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||||
|
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||||
|
*/
|
||||||
|
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||||
|
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||||
|
*/
|
||||||
|
var onExitChord: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
|
||||||
|
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
|
||||||
|
* wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a
|
||||||
|
* quick press, and without an on-screen cue that reads as the shortcut being broken.
|
||||||
|
*/
|
||||||
|
var onExitArmed: ((armed: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||||
|
private var pendingExit: Runnable? = null
|
||||||
|
|
||||||
|
private val inputManager = context.getSystemService(InputManager::class.java)
|
||||||
|
private val listener = object : InputManager.InputDeviceListener {
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) {
|
||||||
|
InputDevice.getDevice(deviceId)?.let { if (isForwardable(it)) openSlot(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) = closeSlot(deviceId)
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
inputManager?.registerInputDeviceListener(listener, mainHandler)
|
||||||
|
// Open a slot for every controller already connected when the session starts — the pads that
|
||||||
|
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
||||||
|
for (id in InputDevice.getDeviceIds()) {
|
||||||
|
InputDevice.getDevice(id)?.let { if (isForwardable(it)) openSlot(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||||
|
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||||
|
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||||
|
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
|
||||||
|
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
|
||||||
|
* chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD`
|
||||||
|
* on the SDL/Apple clients. Any controller can leave.
|
||||||
|
*/
|
||||||
|
fun onButton(event: KeyEvent, bit: Int) {
|
||||||
|
val slot = slotFor(event.device) ?: return
|
||||||
|
when (event.action) {
|
||||||
|
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||||
|
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
|
||||||
|
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||||
|
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
|
||||||
|
*/
|
||||||
|
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||||
|
if (down) {
|
||||||
|
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||||
|
slot.held = slot.held or bit
|
||||||
|
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||||
|
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||||
|
} else {
|
||||||
|
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||||
|
slot.held = slot.held and bit.inv()
|
||||||
|
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||||
|
// holds the full chord.
|
||||||
|
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||||
|
disarmExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||||
|
private fun armExit() {
|
||||||
|
if (pendingExit != null) return // already counting down
|
||||||
|
val r = Runnable {
|
||||||
|
pendingExit = null
|
||||||
|
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
|
||||||
|
// Fire only if the chord survived the full hold on some pad.
|
||||||
|
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||||
|
if (held.isNotEmpty()) {
|
||||||
|
// Release the held buttons + zero the axes on every triggering pad so nothing sticks
|
||||||
|
// host-side once we leave, then signal the deliberate exit.
|
||||||
|
for (s in held) releaseHeld(s)
|
||||||
|
onExitChord?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingExit = r
|
||||||
|
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||||
|
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a pending exit-chord hold timer. */
|
||||||
|
private fun disarmExit() {
|
||||||
|
val wasArmed = pendingExit != null
|
||||||
|
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
pendingExit = null
|
||||||
|
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One joystick MotionEvent — routed to the producing device's own [Gamepad.AxisMapper] (per-device
|
||||||
|
* state). Returns true if consumed. Only a real gamepad drives a pad: a DualSense/DS4 motion-sensor
|
||||||
|
* sibling node classifies as bare joystick (no GAMEPAD source class) and reports every pad axis as
|
||||||
|
* 0, so [isForwardable] filters it out before it can open a slot or clobber axes.
|
||||||
|
*/
|
||||||
|
fun onMotion(event: MotionEvent): Boolean {
|
||||||
|
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||||
|
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||||
|
val dev = event.device ?: return false
|
||||||
|
if (!isForwardable(dev)) return false
|
||||||
|
val slot = slotFor(dev) ?: return false
|
||||||
|
slot.mapper.onMotion(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||||
|
* holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
|
||||||
|
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
|
||||||
|
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
|
||||||
|
*/
|
||||||
|
fun deviceForPad(pad: Int): InputDevice? {
|
||||||
|
for ((deviceId, slot) in slots) {
|
||||||
|
if (slot.index == pad) return InputDevice.getDevice(deviceId)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||||
|
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||||
|
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||||
|
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||||
|
*/
|
||||||
|
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||||
|
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||||
|
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||||
|
private val slot get() = slots[syntheticId]
|
||||||
|
|
||||||
|
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
|
||||||
|
fun button(bit: Int, down: Boolean) {
|
||||||
|
slot?.let { slotButton(it, bit, down, send = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||||
|
fun axis(id: Int, value: Int) {
|
||||||
|
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||||
|
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||||
|
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||||
|
fun close() = closeSlot(syntheticId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||||
|
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||||
|
*/
|
||||||
|
fun openExternal(pref: Int): ExternalPad? {
|
||||||
|
val index = lowestFreeIndex() ?: return null
|
||||||
|
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||||
|
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||||
|
val syntheticId = EXTERNAL_ID_BASE - index
|
||||||
|
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||||
|
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||||
|
return ExternalPad(syntheticId, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||||
|
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
inputManager?.unregisterInputDeviceListener(listener)
|
||||||
|
disarmExit() // drop any pending exit-chord timer so it can't fire after teardown
|
||||||
|
// Snapshot the ids first — closeSlot mutates the map.
|
||||||
|
for (id in slots.keys.toList()) closeSlot(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- slots ----
|
||||||
|
|
||||||
|
/** A real, non-virtual controller we forward — its source classes include GAMEPAD (excludes a pad's bare-joystick sensor node). */
|
||||||
|
private fun isForwardable(dev: InputDevice): Boolean =
|
||||||
|
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slot for [dev], opening one (and declaring the pad) if this device is unseen; null when [dev]
|
||||||
|
* isn't a forwardable controller or every wire index is taken. The [isForwardable] gate lives here —
|
||||||
|
* the single lazy-open chokepoint both [onButton] and [onMotion] funnel through — so no entry point
|
||||||
|
* can open a phantom slot for a virtual/non-gamepad source (the hot-plug listener and init loop
|
||||||
|
* pre-filter and call [openSlot] directly).
|
||||||
|
*/
|
||||||
|
private fun slotFor(dev: InputDevice?): Slot? {
|
||||||
|
if (dev == null) return null
|
||||||
|
slots[dev.id]?.let { return it }
|
||||||
|
if (!isForwardable(dev)) return null
|
||||||
|
return openSlot(dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a slot for [dev] on the lowest free wire index, declaring its kind ([NativeBridge.nativeSendGamepadArrival])
|
||||||
|
* before any input so the host builds a matching virtual device (mixed types across pads).
|
||||||
|
* Idempotent; null when all 16 wire indices are already forwarded.
|
||||||
|
*/
|
||||||
|
private fun openSlot(dev: InputDevice): Slot? {
|
||||||
|
slots[dev.id]?.let { return it }
|
||||||
|
val index = lowestFreeIndex() ?: return null // 16 pads already forwarded — drop this one
|
||||||
|
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||||
|
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||||
|
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||||
|
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||||
|
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||||
|
slots[dev.id] = slot
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush a slot's held wire state (so nothing sticks host-side), signal the removal, and free its
|
||||||
|
* index. Safe against an already-gone device — the flush emits wire events only, no device access.
|
||||||
|
*/
|
||||||
|
private fun closeSlot(deviceId: Int) {
|
||||||
|
val slot = slots.remove(deviceId) ?: return
|
||||||
|
releaseHeld(slot)
|
||||||
|
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||||
|
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||||
|
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||||
|
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||||
|
onSlotClosed?.invoke(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||||
|
private fun releaseHeld(slot: Slot) {
|
||||||
|
var bits = slot.held
|
||||||
|
while (bits != 0) {
|
||||||
|
val bit = bits and -bits // lowest set bit
|
||||||
|
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||||
|
bits = bits and bit.inv()
|
||||||
|
}
|
||||||
|
slot.held = 0
|
||||||
|
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||||
|
private fun lowestFreeIndex(): Int? {
|
||||||
|
val taken = slots.values.mapTo(HashSet()) { it.index }
|
||||||
|
for (i in 0 until MAX_PADS) if (i !in taken) return i
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||||
|
const val MAX_PADS = 16
|
||||||
|
|
||||||
|
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||||
|
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the exit chord must be held before the stream leaves — long enough that an
|
||||||
|
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
|
||||||
|
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
|
||||||
|
*/
|
||||||
|
const val EXIT_HOLD_MS = 1000L
|
||||||
|
|
||||||
|
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||||
|
const val EXTERNAL_ID_BASE = -1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,16 @@ object NativeBridge {
|
|||||||
name: String,
|
name: String,
|
||||||
): 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
|
||||||
|
* yields its wire token ("not-armed", "denied", "approval-timeout", "superseded", "busy",
|
||||||
|
* "rate-limited", "bound-other", "identity-required", "wire-version"); transport-level causes
|
||||||
|
* yield "crypto" (wrong PIN / identity mismatch), "timeout", "io", or "error". Lets the UI say
|
||||||
|
* WHY instead of the old catch-all that blamed the PIN for dead network paths.
|
||||||
|
*/
|
||||||
|
external fun nativeTakeLastError(): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
||||||
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||||
@@ -269,26 +279,52 @@ object NativeBridge {
|
|||||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||||
|
|
||||||
// ---- Gamepad: one pad forwarded as pad 0 (Rust hardcodes flags=0) ----
|
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||||
|
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||||
|
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||||
|
// events into seq'd GamepadState snapshots keyed on this index and owns the per-pad seq.
|
||||||
|
|
||||||
/** One gamepad button transition. bit: a [Gamepad].BTN_* bit. down: press/release. */
|
/** One gamepad button transition on wire pad [pad] (0..15). bit: a [Gamepad].BTN_* bit. down: press/release. */
|
||||||
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean)
|
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean, pad: Int)
|
||||||
|
|
||||||
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
/** One gamepad axis update on wire pad [pad] (0..15). axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int, pad: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare the controller KIND presented on wire pad [pad] (0..15) so the host builds a matching
|
||||||
|
* virtual device (mixed types across pads). pref: a [Gamepad].PREF_* wire byte. Send ONCE when a
|
||||||
|
* pad opens, BEFORE any of its input; an older host ignores it (that pad then uses the handshake's
|
||||||
|
* session-default kind — the pre-existing single-pad behaviour on pad 0).
|
||||||
|
*/
|
||||||
|
external fun nativeSendGamepadArrival(handle: Long, pref: Int, pad: Int)
|
||||||
|
|
||||||
|
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||||
|
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||||
|
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||||
|
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, …);
|
||||||
|
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
|
||||||
|
*/
|
||||||
|
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||||
|
|
||||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
|
* Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 =
|
||||||
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
|
* wire pad index (0..15), bit 48 = has a v2 lease, bits 32..47 = ttl_ms, bits 16..31 = low, bits
|
||||||
|
* 0..15 = high (each amplitude 0..0xFFFF; 0/0 = stop), or -1 on timeout / session closed. Kotlin
|
||||||
|
* routes the update to the controller holding that pad index. Call from a dedicated poll thread.
|
||||||
*/
|
*/
|
||||||
external fun nativeNextRumble(handle: Long): Long
|
external fun nativeNextRumble(handle: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
||||||
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
|
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
||||||
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
|
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
|
||||||
|
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
|
||||||
|
* Returns the byte count, or -1 on timeout / session closed.
|
||||||
*/
|
*/
|
||||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothGatt
|
||||||
|
import android.bluetooth.BluetoothGattCallback
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import android.bluetooth.BluetoothGattDescriptor
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BLE transport for a Steam Controller 2 paired directly with the device (no Puck). The standard
|
||||||
|
* HID service (0x1812) is claimed by the OS (and would feed the pad through the ordinary input
|
||||||
|
* stack in lizard-crippled form), so this talks Valve's vendor GATT service instead — the same
|
||||||
|
* approach Steam itself uses on hosts without a dongle.
|
||||||
|
*
|
||||||
|
* GATT operations are serialized by a small state machine (connect → MTU → discover → subscribe
|
||||||
|
* each notify char → lizard-off → ready); duplicate callbacks (the Android stack sometimes fires
|
||||||
|
* `onMtuChanged` twice) are ignored. Notified state reports arrive with the report-id byte
|
||||||
|
* stripped by the transport, so `0x45` (`ID_STATE_BLE`) is re-prepended for ≥40-byte payloads —
|
||||||
|
* the wire then carries the same id-first framing as USB.
|
||||||
|
*
|
||||||
|
* Requires BLUETOOTH_CONNECT (the caller gates on it); connection priority is bumped to HIGH to
|
||||||
|
* pull the connection interval from ~50 ms down to ~11 ms.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class Sc2BleLink(
|
||||||
|
private val context: Context,
|
||||||
|
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||||
|
private val onClosed: () -> Unit,
|
||||||
|
) {
|
||||||
|
private enum class State { IDLE, CONNECTING, MTU_REQUESTED, DISCOVERING, SUBSCRIBING, READY }
|
||||||
|
|
||||||
|
private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var writeChar: BluetoothGattCharacteristic? = null
|
||||||
|
private val pendingSubs = mutableListOf<BluetoothGattCharacteristic>()
|
||||||
|
private var subsIndex = 0
|
||||||
|
private val writeBusy = AtomicBoolean(false)
|
||||||
|
private var lizardTicker: Thread? = null
|
||||||
|
|
||||||
|
@Volatile private var state = State.IDLE
|
||||||
|
|
||||||
|
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
||||||
|
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
||||||
|
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||||
|
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||||
|
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
||||||
|
fun start(address: String): Boolean {
|
||||||
|
val adapter = manager.adapter ?: return false
|
||||||
|
if (!adapter.isEnabled) return false
|
||||||
|
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||||
|
state = State.CONNECTING
|
||||||
|
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay one raw report from the host: output reports (rumble) ride WRITE_NO_RESPONSE so they
|
||||||
|
* can't queue behind acks at the 25 Hz resend rate; feature reports (settings) use an acked
|
||||||
|
* write. The report-id byte stays in the payload (the firmware's vendor-channel framing).
|
||||||
|
*/
|
||||||
|
fun writeRaw(kind: Int, data: ByteArray) {
|
||||||
|
if (state != State.READY || data.isEmpty()) return
|
||||||
|
val g = gatt ?: return
|
||||||
|
val ch = writeChar ?: return
|
||||||
|
runCatching {
|
||||||
|
ch.value = data
|
||||||
|
ch.writeType = if (kind == 0) {
|
||||||
|
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||||
|
} else {
|
||||||
|
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
}
|
||||||
|
g.writeCharacteristic(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendLizardOff() {
|
||||||
|
if (state != State.READY) return
|
||||||
|
val g = gatt ?: return
|
||||||
|
val ch = writeChar ?: return
|
||||||
|
if (!writeBusy.compareAndSet(false, true)) return // previous acked write still in flight
|
||||||
|
runCatching {
|
||||||
|
ch.value = Sc2Device.DISABLE_LIZARD
|
||||||
|
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
if (!g.writeCharacteristic(ch)) writeBusy.set(false)
|
||||||
|
}.onFailure { writeBusy.set(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect and stop the lizard ticker. Idempotent; does not fire [onClosed]. */
|
||||||
|
fun stop() {
|
||||||
|
lizardTicker?.interrupt()
|
||||||
|
lizardTicker = null
|
||||||
|
runCatching { gatt?.disconnect() }
|
||||||
|
runCatching { gatt?.close() }
|
||||||
|
gatt = null
|
||||||
|
writeChar = null
|
||||||
|
pendingSubs.clear()
|
||||||
|
subsIndex = 0
|
||||||
|
state = State.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback = object : BluetoothGattCallback() {
|
||||||
|
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||||
|
when (newState) {
|
||||||
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
|
// ~11 ms connection interval instead of the ~50 ms default — input latency.
|
||||||
|
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||||
|
if (state == State.CONNECTING) {
|
||||||
|
state = State.MTU_REQUESTED
|
||||||
|
if (!g.requestMtu(DESIRED_MTU)) {
|
||||||
|
state = State.DISCOVERING
|
||||||
|
g.discoverServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
|
val wasLive = state != State.IDLE
|
||||||
|
runCatching { g.close() }
|
||||||
|
gatt = null
|
||||||
|
writeChar = null
|
||||||
|
pendingSubs.clear()
|
||||||
|
subsIndex = 0
|
||||||
|
state = State.IDLE
|
||||||
|
if (wasLive) onClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||||
|
if (state != State.MTU_REQUESTED) return // fired twice on some stacks — act once
|
||||||
|
state = State.DISCOVERING
|
||||||
|
g.discoverServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
|
if (state != State.DISCOVERING || status != BluetoothGatt.GATT_SUCCESS) return
|
||||||
|
val valve = g.getService(VALVE_SERVICE) ?: run {
|
||||||
|
Log.e(TAG, "Valve vendor service missing — not an SC2?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingSubs.clear()
|
||||||
|
writeChar = null
|
||||||
|
for (ch in valve.characteristics) {
|
||||||
|
val short = shortUuid(ch.uuid) ?: continue
|
||||||
|
val canNotify = ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||||
|
val canWrite = ch.properties and (
|
||||||
|
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
||||||
|
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
|
||||||
|
) != 0
|
||||||
|
if (canNotify && short in NOTIFY_LOW..NOTIFY_HIGH) pendingSubs.add(ch)
|
||||||
|
if (canWrite && short in WRITE_LOW..WRITE_HIGH && writeChar == null) writeChar = ch
|
||||||
|
}
|
||||||
|
subsIndex = 0
|
||||||
|
state = State.SUBSCRIBING
|
||||||
|
subscribeNext(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
||||||
|
if (state == State.SUBSCRIBING) subscribeNext(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||||
|
writeBusy.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||||
|
val data = ch.value ?: return
|
||||||
|
// BLE strips the report-id prefix; restore 0x45 on state-sized payloads so the raw
|
||||||
|
// wire framing matches USB. Short payloads (battery/status) pass through as-is.
|
||||||
|
if (data.size >= 40) {
|
||||||
|
val framed = ByteArray(data.size + 1)
|
||||||
|
framed[0] = Sc2Device.ID_STATE_BLE.toByte()
|
||||||
|
System.arraycopy(data, 0, framed, 1, data.size)
|
||||||
|
onReport(framed, framed.size)
|
||||||
|
} else {
|
||||||
|
onReport(data, data.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subscribeNext(g: BluetoothGatt) {
|
||||||
|
if (subsIndex >= pendingSubs.size) {
|
||||||
|
state = State.READY
|
||||||
|
Log.i(TAG, "SC2 BLE link up (${pendingSubs.size} notify chars)")
|
||||||
|
sendLizardOff()
|
||||||
|
// The firmware watchdog re-enables lizard mode; refresh on SDL's cadence until the
|
||||||
|
// host's Steam takes over via the raw plane (its writes land through writeRaw too).
|
||||||
|
lizardTicker = Thread({
|
||||||
|
while (state == State.READY) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(Sc2Device.LIZARD_REFRESH_MS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
sendLizardOff()
|
||||||
|
}
|
||||||
|
}, "pf-sc2-lizard").apply { isDaemon = true; start() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ch = pendingSubs[subsIndex++]
|
||||||
|
g.setCharacteristicNotification(ch, true)
|
||||||
|
val cccd = ch.getDescriptor(CCCD) ?: return subscribeNext(g)
|
||||||
|
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
if (!g.writeDescriptor(cccd)) subscribeNext(g) // lose this one, try the rest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 32-bit short id of a Valve vendor UUID, or null for foreign UUIDs. */
|
||||||
|
private fun shortUuid(uuid: UUID): Long? {
|
||||||
|
val s = uuid.toString()
|
||||||
|
if (!s.endsWith(VALVE_UUID_TAIL)) return null
|
||||||
|
return s.substring(0, 8).toLongOrNull(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Sc2BleLink"
|
||||||
|
|
||||||
|
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||||
|
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||||
|
const val NOTIFY_LOW = 0x100f6c75L
|
||||||
|
const val NOTIFY_HIGH = 0x100f6c7aL
|
||||||
|
const val WRITE_LOW = 0x100f6cb5L
|
||||||
|
const val WRITE_HIGH = 0x100f6cbeL
|
||||||
|
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||||
|
|
||||||
|
/** Enough for a state payload (45 B) + ATT header with margin. */
|
||||||
|
const val DESIRED_MTU = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.util.Log
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One captured Steam Controller 2 — the glue between a transport link ([Sc2UsbLink] /
|
||||||
|
* [Sc2BleLink]) and one of two consumers:
|
||||||
|
*
|
||||||
|
* **Stream mode** (`router != null`, owned by StreamScreen):
|
||||||
|
* - **Raw plane (the point):** every input report is forwarded verbatim
|
||||||
|
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
|
||||||
|
* Steam Input drives like the physical controller.
|
||||||
|
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
||||||
|
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
|
||||||
|
* Xbox 360 pad) still gets a playable controller.
|
||||||
|
* - **Raw return:** the host's hidraw writes (Steam's `0x80` rumble output reports, lizard/IMU
|
||||||
|
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
|
||||||
|
* the real controller's motors/firmware.
|
||||||
|
*
|
||||||
|
* **UI mode** (`router == null`, owned by MainActivity while NOT streaming): the lizard-mode
|
||||||
|
* kb/mouse never produces gamepad events, so an uncaptured SC2 can't drive the console UI at
|
||||||
|
* all. Here the parsed state is edge-detected into [onUiKey] navigation transitions instead
|
||||||
|
* (D-pad + face buttons + Start/Select; the left stick synthesizes one D-pad step per push,
|
||||||
|
* mirroring MainActivity's stick-to-focus behavior for ordinary pads).
|
||||||
|
*
|
||||||
|
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
|
||||||
|
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
|
||||||
|
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
|
||||||
|
* table and chord timer are thread-safe for this (same contract as the feedback poll threads),
|
||||||
|
* and UI-mode consumers hop to the main thread themselves.
|
||||||
|
*/
|
||||||
|
class Sc2Capture(
|
||||||
|
context: Context,
|
||||||
|
private val router: GamepadRouter? = null,
|
||||||
|
) {
|
||||||
|
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
|
||||||
|
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
||||||
|
private var activeLink: Int = LINK_NONE
|
||||||
|
|
||||||
|
/** True when the USB link is a Puck dongle — the only transport whose wireless-status
|
||||||
|
* reports are authoritative. A WIRED pad also emits them, truthfully reporting "no radio
|
||||||
|
* link" — acting on that tore the slot down 255 ms after creation (first on-glass run). */
|
||||||
|
private var dongleLink = false
|
||||||
|
|
||||||
|
private var pad: GamepadRouter.ExternalPad? = null
|
||||||
|
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||||
|
/** Puck connect arrives before its first state report (and therefore before a wire pad exists).
|
||||||
|
* Preserve it so the native virtual Puck slot sees the same connect edge before state. */
|
||||||
|
private val pendingWireless = ByteArray(2)
|
||||||
|
private var pendingWirelessLen = 0
|
||||||
|
|
||||||
|
// Typed-mirror diff state (wire units).
|
||||||
|
private val state = Sc2Device.State()
|
||||||
|
private var wireButtons = 0
|
||||||
|
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||||
|
|
||||||
|
/** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */
|
||||||
|
private val seenIds = HashSet<Int>()
|
||||||
|
|
||||||
|
// UI-mode state (router == null): held navigation keys + the stick's current synth direction.
|
||||||
|
private var uiHeld = HashSet<Int>()
|
||||||
|
private var uiStickDir = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-mode sink: one navigation key transition (an Android `KeyEvent.KEYCODE_*`), invoked on
|
||||||
|
* the LINK thread — the consumer hops to the main thread. Set before [startUsb]/[startBle].
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var onUiKey: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired (link thread) when the capture engages or drops — lets the app surface "SC2
|
||||||
|
* connected" in the console-UI gate and the Controllers screen.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
val isActive: Boolean get() = activeLink != LINK_NONE
|
||||||
|
|
||||||
|
/** First attached SC2/Puck USB device, for the permission flow. */
|
||||||
|
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first already-bonded BLE Steam Controller's address, or null. The caller checks
|
||||||
|
* BLUETOOTH_CONNECT first (without it the bonded list reads as empty anyway).
|
||||||
|
*/
|
||||||
|
fun pairedBleAddress(): String? = ble.pairedControllers().firstOrNull()?.address
|
||||||
|
|
||||||
|
/** Start capturing [dev] over USB (permission already granted). */
|
||||||
|
fun startUsb(dev: UsbDevice): Boolean {
|
||||||
|
if (activeLink != LINK_NONE) return false
|
||||||
|
val ok = usb.start(dev)
|
||||||
|
if (ok) {
|
||||||
|
activeLink = LINK_USB
|
||||||
|
dongleLink = dev.productId != Sc2Device.PID_WIRED
|
||||||
|
onActiveChanged?.invoke(true)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start capturing the bonded BLE controller at [address]. */
|
||||||
|
fun startBle(address: String): Boolean {
|
||||||
|
if (activeLink != LINK_NONE) return false
|
||||||
|
val ok = ble.start(address)
|
||||||
|
if (ok) {
|
||||||
|
activeLink = LINK_BLE
|
||||||
|
onActiveChanged?.invoke(true)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replay a host raw write on the physical pad — wire to [GamepadFeedback.onHidRaw]. */
|
||||||
|
fun onHidRaw(padIndex: Int, kind: Int, data: ByteArray) {
|
||||||
|
if (padIndex != pad?.index) return // addressed to some other controller
|
||||||
|
when (activeLink) {
|
||||||
|
LINK_USB -> usb.writeRaw(kind, data)
|
||||||
|
LINK_BLE -> ble.writeRaw(kind, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||||
|
fun stop() {
|
||||||
|
val wasActive = activeLink != LINK_NONE
|
||||||
|
when (activeLink) {
|
||||||
|
LINK_USB -> usb.stop()
|
||||||
|
LINK_BLE -> ble.stop()
|
||||||
|
}
|
||||||
|
activeLink = LINK_NONE
|
||||||
|
dongleLink = false
|
||||||
|
releaseSlot()
|
||||||
|
releaseUiKeys()
|
||||||
|
if (wasActive) onActiveChanged?.invoke(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- link callbacks (link thread) ----
|
||||||
|
|
||||||
|
private fun onReport(report: ByteArray, len: Int) {
|
||||||
|
val id = report[0].toInt() and 0xFF
|
||||||
|
if (seenIds.add(id)) Log.i(TAG, "SC2 report id=0x%02x seen (len=%d)".format(id, len))
|
||||||
|
// Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees
|
||||||
|
// its wire index + the host's virtual device). A wired/BLE pad emits it too — truthfully
|
||||||
|
// saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise
|
||||||
|
// marks the controller connected unconditionally and reconnects on any state report).
|
||||||
|
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
|
||||||
|
if (dongleLink) {
|
||||||
|
when (report[1].toInt() and 0xFF) {
|
||||||
|
Sc2Device.WIRELESS_CONNECT -> {
|
||||||
|
pendingWireless[0] = report[0]
|
||||||
|
pendingWireless[1] = report[1]
|
||||||
|
pendingWirelessLen = 2
|
||||||
|
}
|
||||||
|
Sc2Device.WIRELESS_DISCONNECT -> {
|
||||||
|
pendingWirelessLen = 0
|
||||||
|
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
|
||||||
|
releaseSlot()
|
||||||
|
releaseUiKeys()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Sc2Device.parseState(report, len, state)) {
|
||||||
|
// Battery/status and future report types still belong to the as-is stream.
|
||||||
|
forwardRaw(report, len)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (router == null) {
|
||||||
|
mirrorUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val pref = if (dongleLink) {
|
||||||
|
Gamepad.PREF_STEAMCONTROLLER2_PUCK
|
||||||
|
} else {
|
||||||
|
Gamepad.PREF_STEAMCONTROLLER2
|
||||||
|
}
|
||||||
|
val p = pad ?: router.openExternal(pref)?.also {
|
||||||
|
pad = it
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"SC2 captured → wire pad ${it.index} (${if (dongleLink) "Puck" else "direct"} passthrough)",
|
||||||
|
)
|
||||||
|
if (pendingWirelessLen > 0) {
|
||||||
|
forwardRaw(pendingWireless, pendingWirelessLen)
|
||||||
|
pendingWirelessLen = 0
|
||||||
|
}
|
||||||
|
} ?: return // all 16 wire indices taken — drop until one frees
|
||||||
|
forwardRaw(report, len)
|
||||||
|
mirrorTyped(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forwardRaw(report: ByteArray, len: Int) {
|
||||||
|
val p = pad ?: return
|
||||||
|
val n = len.coerceAtMost(rawBuf.capacity())
|
||||||
|
rawBuf.clear()
|
||||||
|
rawBuf.put(report, 0, n)
|
||||||
|
p.hidReport(rawBuf, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||||
|
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||||
|
val wired = Sc2Device.wireButtons(state.buttons)
|
||||||
|
var changed = wired xor wireButtons
|
||||||
|
while (changed != 0) {
|
||||||
|
val bit = changed and -changed // lowest changed bit
|
||||||
|
p.button(bit, wired and bit != 0)
|
||||||
|
changed = changed and bit.inv()
|
||||||
|
}
|
||||||
|
wireButtons = wired
|
||||||
|
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||||
|
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||||
|
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||||
|
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||||
|
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||||
|
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||||
|
if (lastAxis[id] == v) return
|
||||||
|
lastAxis[id] = v
|
||||||
|
p.axis(id, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI mode: edge-detect the parsed state into navigation key transitions. Buttons map to
|
||||||
|
* their Android keycodes (press AND release, so the focus system sees real holds); the left
|
||||||
|
* stick synthesizes ONE D-pad step per push past half deflection — the same single-move
|
||||||
|
* behavior MainActivity gives ordinary pads' sticks.
|
||||||
|
*/
|
||||||
|
private fun mirrorUi() {
|
||||||
|
val sink = onUiKey ?: return
|
||||||
|
val held = HashSet<Int>(8)
|
||||||
|
var i = 0
|
||||||
|
while (i < UI_KEY_MAP.size) {
|
||||||
|
if (state.buttons and UI_KEY_MAP[i] != 0) held.add(UI_KEY_MAP[i + 1])
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
for (key in held) if (key !in uiHeld) sink(key, true)
|
||||||
|
for (key in uiHeld) if (key !in held) sink(key, false)
|
||||||
|
uiHeld = held
|
||||||
|
// Left stick → a HELD D-pad direction (device convention: +y = up): pressed while
|
||||||
|
// deflected, released on centre/direction change. The console UI's probe machinery
|
||||||
|
// turns a held direction into its own auto-repeat, exactly like a physical D-pad; the
|
||||||
|
// focus-hook path moves once per press edge either way.
|
||||||
|
val dir = when {
|
||||||
|
state.lsX <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||||||
|
state.lsX >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
|
||||||
|
state.lsY >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_UP
|
||||||
|
state.lsY <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
if (dir != uiStickDir) {
|
||||||
|
// The D-pad bits share these keycodes; don't release a direction the physical
|
||||||
|
// D-pad itself still holds (uiHeld tracks the button-sourced state).
|
||||||
|
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
||||||
|
if (dir != 0 && dir !in uiHeld) sink(dir, true)
|
||||||
|
uiStickDir = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release every held UI-mode key (link drop / stop) so nothing sticks in the focus system. */
|
||||||
|
private fun releaseUiKeys() {
|
||||||
|
val sink = onUiKey
|
||||||
|
if (sink != null) {
|
||||||
|
for (key in uiHeld) sink(key, false)
|
||||||
|
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
||||||
|
}
|
||||||
|
uiHeld = HashSet()
|
||||||
|
uiStickDir = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLinkClosed() {
|
||||||
|
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||||
|
activeLink = LINK_NONE
|
||||||
|
dongleLink = false
|
||||||
|
releaseSlot()
|
||||||
|
releaseUiKeys()
|
||||||
|
onActiveChanged?.invoke(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseSlot() {
|
||||||
|
pad?.close()
|
||||||
|
pad = null
|
||||||
|
wireButtons = 0
|
||||||
|
lastAxis.fill(Int.MIN_VALUE)
|
||||||
|
pendingWirelessLen = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Sc2Capture"
|
||||||
|
const val LINK_NONE = 0
|
||||||
|
const val LINK_USB = 1
|
||||||
|
const val LINK_BLE = 2
|
||||||
|
|
||||||
|
/** Half deflection (device i16 range) — the stick-to-focus threshold. */
|
||||||
|
const val STICK_NAV = 16384
|
||||||
|
|
||||||
|
/** UI-mode mapping: SC2 button bit → Android keycode, as (bit, key) pairs. */
|
||||||
|
val UI_KEY_MAP = intArrayOf(
|
||||||
|
Sc2Device.DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||||
|
Sc2Device.DPAD_DOWN, android.view.KeyEvent.KEYCODE_DPAD_DOWN,
|
||||||
|
Sc2Device.DPAD_LEFT, android.view.KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
Sc2Device.DPAD_RIGHT, android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
|
Sc2Device.A, android.view.KeyEvent.KEYCODE_BUTTON_A,
|
||||||
|
Sc2Device.B, android.view.KeyEvent.KEYCODE_BUTTON_B,
|
||||||
|
Sc2Device.X, android.view.KeyEvent.KEYCODE_BUTTON_X,
|
||||||
|
Sc2Device.Y, android.view.KeyEvent.KEYCODE_BUTTON_Y,
|
||||||
|
Sc2Device.LB, android.view.KeyEvent.KEYCODE_BUTTON_L1,
|
||||||
|
Sc2Device.RB, android.view.KeyEvent.KEYCODE_BUTTON_R1,
|
||||||
|
Sc2Device.MENU, android.view.KeyEvent.KEYCODE_BUTTON_START,
|
||||||
|
Sc2Device.VIEW, android.view.KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton") protocol constants + the light state
|
||||||
|
* parser the CLIENT needs. The full report rides the wire verbatim (`nativeSendPadHidReport` →
|
||||||
|
* the host's as-is virtual pad); this parser only extracts what the client itself consumes: the
|
||||||
|
* button word for the typed mirror + exit chord, and sticks/triggers for the degrade path.
|
||||||
|
*
|
||||||
|
* Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||||
|
* (Valve-maintained), mirrored host-side in `punktfunk-host`'s `triton_proto.rs`.
|
||||||
|
*/
|
||||||
|
object Sc2Device {
|
||||||
|
const val VID_VALVE = 0x28DE
|
||||||
|
|
||||||
|
/** Wired controller. */
|
||||||
|
const val PID_WIRED = 0x1302
|
||||||
|
|
||||||
|
/** Direct BLE identity (transport handled by [Sc2BleLink], not USB). */
|
||||||
|
const val PID_BLE = 0x1303
|
||||||
|
|
||||||
|
/** The wireless Puck dongles (Proteus / Nereid) — controller on USB interfaces 2..5. */
|
||||||
|
const val PID_DONGLE_PROTEUS = 0x1304
|
||||||
|
const val PID_DONGLE_NEREID = 0x1305
|
||||||
|
|
||||||
|
val USB_PIDS = setOf(PID_WIRED, PID_DONGLE_PROTEUS, PID_DONGLE_NEREID)
|
||||||
|
|
||||||
|
/** Dongle interface range that carries controllers (SDL: "interfaces 2..5, currently"). */
|
||||||
|
val DONGLE_IFACES = 2..5
|
||||||
|
|
||||||
|
// Input report ids (`ETritonReportIDTypes`). State layouts share every offset the client
|
||||||
|
// reads (seq/buttons/triggers/sticks); 0x47 only diverges from byte 18 (trackpad timestamp).
|
||||||
|
const val ID_STATE = 0x42
|
||||||
|
const val ID_BATTERY = 0x43
|
||||||
|
const val ID_STATE_BLE = 0x45
|
||||||
|
const val ID_WIRELESS_X = 0x46
|
||||||
|
const val ID_STATE_TIMESTAMP = 0x47
|
||||||
|
const val ID_WIRELESS = 0x79
|
||||||
|
|
||||||
|
/** Wireless status payload byte: controller connected/disconnected through the Puck. */
|
||||||
|
const val WIRELESS_DISCONNECT = 1
|
||||||
|
const val WIRELESS_CONNECT = 2
|
||||||
|
|
||||||
|
// Button bits in the state report's u32 (SDL `TritonButtons`).
|
||||||
|
const val A = 0x00000001
|
||||||
|
const val B = 0x00000002
|
||||||
|
const val X = 0x00000004
|
||||||
|
const val Y = 0x00000008
|
||||||
|
const val QAM = 0x00000010
|
||||||
|
const val R3 = 0x00000020
|
||||||
|
const val VIEW = 0x00000040
|
||||||
|
const val R4 = 0x00000080
|
||||||
|
const val R5 = 0x00000100
|
||||||
|
const val RB = 0x00000200
|
||||||
|
const val DPAD_DOWN = 0x00000400
|
||||||
|
const val DPAD_RIGHT = 0x00000800
|
||||||
|
const val DPAD_LEFT = 0x00001000
|
||||||
|
const val DPAD_UP = 0x00002000
|
||||||
|
const val MENU = 0x00004000
|
||||||
|
const val L3 = 0x00008000
|
||||||
|
const val STEAM = 0x00010000
|
||||||
|
const val L4 = 0x00020000
|
||||||
|
const val L5 = 0x00040000
|
||||||
|
const val LB = 0x00080000
|
||||||
|
const val RPAD_CLICK = 0x00400000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
|
||||||
|
* `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
|
||||||
|
* [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size. The firmware watchdog
|
||||||
|
* re-enables lizard mode after a few seconds of silence, so this is re-sent every
|
||||||
|
* [LIZARD_REFRESH_MS] (SDL's cadence) — and the host's Steam sends its own through the raw
|
||||||
|
* plane once it grabs the virtual pad, which lands here too.
|
||||||
|
*/
|
||||||
|
val DISABLE_LIZARD: ByteArray = ByteArray(64).also {
|
||||||
|
it[0] = 0x01 // feature report id
|
||||||
|
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||||
|
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||||
|
it[3] = 9 // SETTING_LIZARD_MODE
|
||||||
|
// [4..6] = LIZARD_MODE_OFF (0) — already zero
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force firmware-calibrated signed i16 stick coordinates. Steam sends this during physical
|
||||||
|
* controller initialization (`SETTING_ENABLE_RAW_JOYSTICK` = 0x2e, value 0); without it a
|
||||||
|
* controller previously opened in raw mode reports ADC coordinates around 0..3200, which a
|
||||||
|
* Triton consumer interprets as only a few percent of full travel.
|
||||||
|
*/
|
||||||
|
val NORMALIZE_JOYSTICKS: ByteArray = ByteArray(64).also {
|
||||||
|
it[0] = 0x01 // feature report id
|
||||||
|
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||||
|
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||||
|
it[3] = 0x2E // SETTING_ENABLE_RAW_JOYSTICK
|
||||||
|
// [4..6] = disabled (0) — firmware emits calibrated signed i16 values
|
||||||
|
}
|
||||||
|
|
||||||
|
const val LIZARD_REFRESH_MS = 3000L
|
||||||
|
|
||||||
|
/** Wire mapping: SC2 button bit → punktfunk `Gamepad.BTN_*`, the inverse of the host's
|
||||||
|
* typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
|
||||||
|
* PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. */
|
||||||
|
private val WIRE_MAP = intArrayOf(
|
||||||
|
A, Gamepad.BTN_A,
|
||||||
|
B, Gamepad.BTN_B,
|
||||||
|
X, Gamepad.BTN_X,
|
||||||
|
Y, Gamepad.BTN_Y,
|
||||||
|
LB, Gamepad.BTN_LB,
|
||||||
|
RB, Gamepad.BTN_RB,
|
||||||
|
VIEW, Gamepad.BTN_BACK,
|
||||||
|
MENU, Gamepad.BTN_START,
|
||||||
|
STEAM, Gamepad.BTN_GUIDE,
|
||||||
|
L3, Gamepad.BTN_LS_CLICK,
|
||||||
|
R3, Gamepad.BTN_RS_CLICK,
|
||||||
|
DPAD_UP, Gamepad.BTN_DPAD_UP,
|
||||||
|
DPAD_DOWN, Gamepad.BTN_DPAD_DOWN,
|
||||||
|
DPAD_LEFT, Gamepad.BTN_DPAD_LEFT,
|
||||||
|
DPAD_RIGHT, Gamepad.BTN_DPAD_RIGHT,
|
||||||
|
QAM, Gamepad.BTN_MISC1,
|
||||||
|
R4, Gamepad.BTN_PADDLE1,
|
||||||
|
L4, Gamepad.BTN_PADDLE2,
|
||||||
|
R5, Gamepad.BTN_PADDLE3,
|
||||||
|
L5, Gamepad.BTN_PADDLE4,
|
||||||
|
RPAD_CLICK, Gamepad.BTN_TOUCHPAD,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Translate an SC2 button word into the wire `Gamepad.BTN_*` bitmask. */
|
||||||
|
fun wireButtons(sc2: Int): Int {
|
||||||
|
var out = 0
|
||||||
|
var i = 0
|
||||||
|
while (i < WIRE_MAP.size) {
|
||||||
|
if (sc2 and WIRE_MAP[i] != 0) out = out or WIRE_MAP[i + 1]
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The typed-mirror fields of one state report (buttons/sticks/triggers only). */
|
||||||
|
class State {
|
||||||
|
var buttons = 0 // SC2 bit layout
|
||||||
|
var lsX = 0; var lsY = 0 // i16, +y = up (device convention = wire convention)
|
||||||
|
var rsX = 0; var rsY = 0
|
||||||
|
var lt = 0; var rt = 0 // 0..255 (device 0..32767 scaled down)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
|
||||||
|
* offsets for everything read here) into [out]. Returns false for non-state / short reports.
|
||||||
|
*/
|
||||||
|
fun parseState(report: ByteArray, len: Int, out: State): Boolean {
|
||||||
|
if (len < 18) return false
|
||||||
|
when (report[0].toInt() and 0xFF) {
|
||||||
|
ID_STATE, ID_STATE_BLE, ID_STATE_TIMESTAMP -> {}
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
fun i16(o: Int) = ((report[o + 1].toInt() shl 8) or (report[o].toInt() and 0xFF)).toShort().toInt()
|
||||||
|
out.buttons = (report[2].toInt() and 0xFF) or
|
||||||
|
((report[3].toInt() and 0xFF) shl 8) or
|
||||||
|
((report[4].toInt() and 0xFF) shl 16) or
|
||||||
|
((report[5].toInt() and 0xFF) shl 24)
|
||||||
|
out.lt = (i16(6).coerceIn(0, 32767)) shr 7
|
||||||
|
out.rt = (i16(8).coerceIn(0, 32767)) shr 7
|
||||||
|
out.lsX = i16(10); out.lsY = i16(12)
|
||||||
|
out.rsX = i16(14); out.rsY = i16(16)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.hardware.usb.UsbConstants
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
|
import android.hardware.usb.UsbInterface
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
|
import android.hardware.usb.UsbRequest
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.TimeoutException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
||||||
|
* dongle (`1304`/`1305`). Claims the controller interface(s) — detaching the OS input stack, so
|
||||||
|
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
||||||
|
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
||||||
|
* writes (Steam's rumble output reports / settings feature reports) back to the device.
|
||||||
|
*
|
||||||
|
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
|
||||||
|
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
||||||
|
* only interface 2 read silence while Android's input stack kept the others (the round-2
|
||||||
|
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
||||||
|
* streams state becomes the write target for rumble/settings.
|
||||||
|
*
|
||||||
|
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||||
|
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
||||||
|
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||||
|
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||||
|
*/
|
||||||
|
class Sc2UsbLink(
|
||||||
|
private val context: Context,
|
||||||
|
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||||
|
private val onClosed: () -> Unit,
|
||||||
|
) {
|
||||||
|
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
|
||||||
|
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||||
|
private class Claim(
|
||||||
|
val iface: UsbInterface,
|
||||||
|
val epIn: UsbEndpoint,
|
||||||
|
val epOut: UsbEndpoint?,
|
||||||
|
) {
|
||||||
|
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||||
|
var inReq: UsbRequest? = null
|
||||||
|
var outReq: UsbRequest? = null
|
||||||
|
var outBusy = false
|
||||||
|
var reports = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connection: UsbDeviceConnection? = null
|
||||||
|
private var device: UsbDevice? = null
|
||||||
|
private var claims: List<Claim> = emptyList()
|
||||||
|
|
||||||
|
/** The claim whose IN endpoint last produced data — where rumble/settings writes go.
|
||||||
|
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||||
|
@Volatile private var activeClaim: Claim? = null
|
||||||
|
|
||||||
|
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — only
|
||||||
|
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
|
||||||
|
* returns ANY completed request; a second waiter would steal the reader's completions). */
|
||||||
|
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||||
|
|
||||||
|
private var reader: Thread? = null
|
||||||
|
private var detachReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
@Volatile private var running = false
|
||||||
|
|
||||||
|
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||||
|
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
||||||
|
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||||
|
* obtained USB permission. Returns false when nothing could be claimed.
|
||||||
|
*/
|
||||||
|
fun start(dev: UsbDevice): Boolean {
|
||||||
|
if (!usb.hasPermission(dev)) {
|
||||||
|
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val conn = usb.openDevice(dev) ?: run {
|
||||||
|
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val claimed = claimControllerInterfaces(dev, conn)
|
||||||
|
if (claimed.isEmpty()) {
|
||||||
|
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||||
|
conn.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
connection = conn
|
||||||
|
device = dev
|
||||||
|
claims = claimed
|
||||||
|
running = true
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
|
||||||
|
dev.productId,
|
||||||
|
claimed.joinToString {
|
||||||
|
"%d(in=0x%02x out=%s)".format(
|
||||||
|
it.iface.id, it.epIn.address,
|
||||||
|
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||||
|
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||||
|
if (gone?.deviceName == dev.deviceName) {
|
||||||
|
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
|
||||||
|
if (running) {
|
||||||
|
running = false
|
||||||
|
onClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detachReceiver = receiver
|
||||||
|
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
|
context.registerReceiver(receiver, filter)
|
||||||
|
}
|
||||||
|
claimed.forEach { configureInputMode(conn, it.iface.id) }
|
||||||
|
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
|
||||||
|
* of a Puck's controller slots (interfaces 2..5 — the controller may be bonded to any of
|
||||||
|
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
|
||||||
|
* Android's own input stack while captured.
|
||||||
|
*/
|
||||||
|
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||||
|
val dongle = dev.productId != Sc2Device.PID_WIRED
|
||||||
|
val out = mutableListOf<Claim>()
|
||||||
|
for (i in 0 until dev.interfaceCount) {
|
||||||
|
val iface = dev.getInterface(i)
|
||||||
|
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
|
||||||
|
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||||
|
iface.interfaceClass == 0xFF
|
||||||
|
if (!hidOrVendor) continue
|
||||||
|
var inEp: UsbEndpoint? = null
|
||||||
|
var outEp: UsbEndpoint? = null
|
||||||
|
for (e in 0 until iface.endpointCount) {
|
||||||
|
val ep = iface.getEndpoint(e)
|
||||||
|
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||||
|
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||||
|
if (!usable) continue
|
||||||
|
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||||
|
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||||
|
}
|
||||||
|
if (inEp == null) continue
|
||||||
|
if (conn.claimInterface(iface, true)) {
|
||||||
|
out.add(Claim(iface, inEp, outEp))
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "could not claim iface ${iface.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||||
|
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||||
|
*/
|
||||||
|
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||||
|
val live = claims.filter { c ->
|
||||||
|
val req = UsbRequest()
|
||||||
|
if (!req.initialize(conn, c.epIn)) {
|
||||||
|
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
req.clientData = c
|
||||||
|
c.inReq = req
|
||||||
|
c.epOut?.let { ep ->
|
||||||
|
val o = UsbRequest()
|
||||||
|
if (o.initialize(conn, ep)) {
|
||||||
|
o.clientData = c
|
||||||
|
c.outReq = o
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.inBuf.clear()
|
||||||
|
req.queue(c.inBuf)
|
||||||
|
}
|
||||||
|
if (live.isEmpty()) {
|
||||||
|
Log.e(TAG, "no IN request could be queued")
|
||||||
|
finishReader(claims)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scratch = ByteArray(64)
|
||||||
|
var lastLizard = android.os.SystemClock.elapsedRealtime()
|
||||||
|
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||||
|
try {
|
||||||
|
while (running) {
|
||||||
|
val now = android.os.SystemClock.elapsedRealtime()
|
||||||
|
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||||
|
// Refresh both required firmware modes. The raw-joystick setting is normally
|
||||||
|
// persistent, but replaying it also repairs a host/driver that enabled ADC
|
||||||
|
// coordinates after capture started.
|
||||||
|
val target = activeClaim
|
||||||
|
if (target != null) configureInputMode(conn, target.iface.id)
|
||||||
|
else live.forEach { configureInputMode(conn, it.iface.id) }
|
||||||
|
lastLizard = now
|
||||||
|
}
|
||||||
|
// Submit the next pending OUT report on the active (else first) interface.
|
||||||
|
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||||
|
if (outTarget != null) {
|
||||||
|
outQueue.poll()?.let { data ->
|
||||||
|
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val done = try {
|
||||||
|
conn.requestWait(READ_TIMEOUT_MS)
|
||||||
|
} catch (_: TimeoutException) {
|
||||||
|
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||||
|
// detach broadcast is the real signal.
|
||||||
|
errorsSince = 0L
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (done == null) {
|
||||||
|
// Hard error. On a real unplug these storm continuously (the detach
|
||||||
|
// broadcast usually beats us to it); tolerate transient ones.
|
||||||
|
if (errorsSince == 0L) errorsSince = now
|
||||||
|
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||||
|
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errorsSince = 0L
|
||||||
|
val claim = done.clientData as? Claim ?: continue
|
||||||
|
if (done === claim.inReq) {
|
||||||
|
val n = claim.inBuf.position()
|
||||||
|
if (n > 0) {
|
||||||
|
claim.inBuf.flip()
|
||||||
|
claim.inBuf.get(scratch, 0, n)
|
||||||
|
if (claim.reports++ == 0L) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"SC2 first report on iface %d: id=0x%02x len=%d".format(
|
||||||
|
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
activeClaim = claim
|
||||||
|
onReport(scratch, n)
|
||||||
|
}
|
||||||
|
claim.inBuf.clear()
|
||||||
|
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||||
|
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if (done === claim.outReq) {
|
||||||
|
claim.outBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
finishReader(claims)
|
||||||
|
}
|
||||||
|
if (running) {
|
||||||
|
running = false
|
||||||
|
onClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishReader(claims: List<Claim>) {
|
||||||
|
for (c in claims) {
|
||||||
|
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||||
|
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||||
|
c.inReq = null
|
||||||
|
c.outReq = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
||||||
|
* rumble & friends — the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
|
||||||
|
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
||||||
|
* report, id byte first, exactly as hidapi framed it host-side.
|
||||||
|
*/
|
||||||
|
fun writeRaw(kind: Int, data: ByteArray) {
|
||||||
|
if (data.isEmpty()) return
|
||||||
|
when (kind) {
|
||||||
|
0 -> {
|
||||||
|
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||||
|
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||||
|
// newest-wins: these are level-styled commands the host re-sends anyway.
|
||||||
|
while (outQueue.size >= 32) outQueue.poll()
|
||||||
|
outQueue.offer(data)
|
||||||
|
} else {
|
||||||
|
setReport(REPORT_TYPE_OUTPUT, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setReport(type: Int, data: ByteArray) {
|
||||||
|
val conn = connection ?: return
|
||||||
|
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||||
|
sendReport(conn, ifId, type, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||||
|
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
|
||||||
|
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
|
||||||
|
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||||
|
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||||
|
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||||
|
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||||
|
*/
|
||||||
|
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||||
|
val id = data[0].toInt() and 0xFF
|
||||||
|
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||||
|
conn.controlTransfer(
|
||||||
|
0x21, // host→device, class, interface
|
||||||
|
0x09, // SET_REPORT
|
||||||
|
(type shl 8) or id,
|
||||||
|
ifaceId,
|
||||||
|
payload,
|
||||||
|
payload.size,
|
||||||
|
WRITE_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
|
detachReceiver = null
|
||||||
|
runCatching { reader?.join(1000) }
|
||||||
|
reader = null
|
||||||
|
outQueue.clear()
|
||||||
|
activeClaim = null
|
||||||
|
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||||
|
claims = emptyList()
|
||||||
|
runCatching { connection?.close() }
|
||||||
|
connection = null
|
||||||
|
device = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Sc2UsbLink"
|
||||||
|
const val READ_TIMEOUT_MS = 100L
|
||||||
|
const val WRITE_TIMEOUT_MS = 250
|
||||||
|
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||||
|
const val ERROR_UNPLUG_MS = 2000L
|
||||||
|
const val REPORT_TYPE_OUTPUT = 0x02
|
||||||
|
const val REPORT_TYPE_FEATURE = 0x03
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use ndk::media::media_format::MediaFormat;
|
|||||||
use ndk::native_window::NativeWindow;
|
use ndk::native_window::NativeWindow;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
|
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||||
use punktfunk_core::session::Frame;
|
use punktfunk_core::session::Frame;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
@@ -208,9 +209,15 @@ fn run_sync(
|
|||||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||||
// round-trip) and we only pop the next one once it's queued.
|
// round-trip) and we only pop the next one once it's queued.
|
||||||
let mut pending: Option<Frame> = None;
|
let mut pending: Option<Frame> = None;
|
||||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||||
// climbs.
|
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
||||||
let mut last_dropped = client.frames_dropped();
|
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
||||||
|
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
||||||
|
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
||||||
|
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
||||||
|
// re-anchor signalling the platform decoder doesn't expose.
|
||||||
|
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||||
|
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
@@ -222,9 +229,11 @@ fn run_sync(
|
|||||||
// reclaimed after the codec is dropped below.
|
// reclaimed after the codec is dropped below.
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
// for the `decode` stage. Fed while the HUD is visible OR the adaptive-bitrate controller wants
|
||||||
|
// the decode signal (`measure_decode`) — the decoder-backlog bottleneck the network can't see.
|
||||||
|
let measure_decode = client.wants_decode_latency();
|
||||||
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||||
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||||
@@ -243,6 +252,20 @@ fn run_sync(
|
|||||||
if pending.is_none() {
|
if pending.is_none() {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
|
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
||||||
|
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
||||||
|
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||||
|
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||||
|
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||||
|
if client.note_frame_index(frame.frame_index) {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||||
|
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||||
|
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
if fed == 0 {
|
if fed == 0 {
|
||||||
let p = &frame.data;
|
let p = &frame.data;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -251,22 +274,25 @@ fn run_sync(
|
|||||||
&p[..p.len().min(6)]
|
&p[..p.len().min(6)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||||
// the output buffer) for the decoded-point pairing in `drain`.
|
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||||
if stats.enabled() {
|
if stats.enabled() || measure_decode {
|
||||||
let received_ns = now_realtime_ns();
|
let received_ns = now_realtime_ns();
|
||||||
|
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||||
|
if in_flight.len() > IN_FLIGHT_CAP {
|
||||||
|
in_flight.pop_front(); // stale — codec never echoed it back
|
||||||
|
}
|
||||||
|
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||||
|
// capture_pts.
|
||||||
|
if stats.enabled() {
|
||||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
.then_some((lat_ns / 1000) as u64);
|
.then_some((lat_ns / 1000) as u64);
|
||||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
|
||||||
if in_flight.len() > IN_FLIGHT_CAP {
|
|
||||||
in_flight.pop_front(); // stale — codec never echoed it back
|
|
||||||
}
|
|
||||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||||
// 0xCF host timings that have arrived — host = the host's own
|
// 0xCF host timings that have arrived — host = the host's own
|
||||||
// capture→sent, network = our capture→received minus it (per-frame
|
// capture→sent, network = our capture→received minus it (per-frame
|
||||||
@@ -278,7 +304,8 @@ fn run_sync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
if let Some(i) =
|
||||||
|
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
{
|
{
|
||||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
stats.note_host_split(
|
stats.note_host_split(
|
||||||
@@ -288,6 +315,7 @@ fn run_sync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pending = Some(frame);
|
pending = Some(frame);
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below
|
||||||
@@ -324,6 +352,8 @@ fn run_sync(
|
|||||||
};
|
};
|
||||||
let (r, d) = drain(
|
let (r, d) = drain(
|
||||||
&codec,
|
&codec,
|
||||||
|
&client,
|
||||||
|
measure_decode,
|
||||||
&window,
|
&window,
|
||||||
&mut applied_ds,
|
&mut applied_ds,
|
||||||
wait,
|
wait,
|
||||||
@@ -331,6 +361,8 @@ fn run_sync(
|
|||||||
&mut in_flight,
|
&mut in_flight,
|
||||||
clock_offset.load(Ordering::Relaxed),
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
);
|
);
|
||||||
rendered += r;
|
rendered += r;
|
||||||
discarded += d;
|
discarded += d;
|
||||||
@@ -370,21 +402,19 @@ fn run_sync(
|
|||||||
work_accum_ns = 0;
|
work_accum_ns = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
|
||||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
||||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
||||||
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
|
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||||
// decode stays wedged for several frames until the IDR lands, so requesting every frame
|
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||||
// would flood the control stream.
|
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||||
let dropped = client.frames_dropped();
|
|
||||||
if dropped > last_dropped {
|
|
||||||
last_dropped = dropped;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
if gate.poll(client.frames_dropped(), now)
|
||||||
|
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
|
{
|
||||||
last_kf_req = Some(now);
|
last_kf_req = Some(now);
|
||||||
let _ = client.request_keyframe();
|
let _ = client.request_keyframe();
|
||||||
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,8 +732,10 @@ struct OutputReady {
|
|||||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
/// 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.
|
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||||
enum DecodeEvent {
|
enum DecodeEvent {
|
||||||
/// A received access unit from the feeder, ready to queue into the decoder.
|
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||||
Au(Frame),
|
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||||
|
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||||
|
Au(Frame, bool),
|
||||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
InputAvailable(usize),
|
InputAvailable(usize),
|
||||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||||
@@ -843,6 +875,9 @@ fn run_async(
|
|||||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||||
// HUD is visible.
|
// HUD is visible.
|
||||||
let clock_offset = client.clock_offset_shared();
|
let clock_offset = client.clock_offset_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();
|
||||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||||
@@ -863,7 +898,15 @@ fn run_async(
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-decode-feed".into())
|
.name("pf-decode-feed".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
|
feeder_loop(
|
||||||
|
client,
|
||||||
|
stats,
|
||||||
|
measure_decode,
|
||||||
|
in_flight,
|
||||||
|
clock_offset,
|
||||||
|
shutdown,
|
||||||
|
ev_tx,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
};
|
};
|
||||||
@@ -889,7 +932,12 @@ fn run_async(
|
|||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||||
let mut oversized_dropped: u64 = 0;
|
let mut oversized_dropped: u64 = 0;
|
||||||
let mut last_dropped = client.frames_dropped();
|
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||||
|
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||||
|
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||||
|
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||||
|
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||||
|
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||||
@@ -915,6 +963,8 @@ fn run_async(
|
|||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
@@ -927,6 +977,8 @@ fn run_async(
|
|||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
@@ -944,6 +996,8 @@ fn run_async(
|
|||||||
let had_output = !ready.is_empty();
|
let had_output = !ready.is_empty();
|
||||||
present_ready(
|
present_ready(
|
||||||
&codec,
|
&codec,
|
||||||
|
&client,
|
||||||
|
measure_decode,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&stats,
|
&stats,
|
||||||
&in_flight,
|
&in_flight,
|
||||||
@@ -951,6 +1005,8 @@ fn run_async(
|
|||||||
&tracker,
|
&tracker,
|
||||||
&mut rendered,
|
&mut rendered,
|
||||||
&mut discarded,
|
&mut discarded,
|
||||||
|
&mut gate,
|
||||||
|
&mut recovery_flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||||
@@ -982,19 +1038,21 @@ fn run_async(
|
|||||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
|
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
||||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
||||||
// control stream.
|
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
||||||
let dropped = client.frames_dropped();
|
// throttle so a multi-frame recovery gap can't flood the control stream.
|
||||||
if dropped > last_dropped || aus_dropped > 0 {
|
|
||||||
last_dropped = dropped;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
if aus_dropped > 0 {
|
||||||
|
gate.arm(now);
|
||||||
|
}
|
||||||
|
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||||
|
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
|
{
|
||||||
last_kf_req = Some(now);
|
last_kf_req = Some(now);
|
||||||
let _ = client.request_keyframe();
|
let _ = client.request_keyframe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let _ = codec.stop();
|
let _ = codec.stop();
|
||||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||||
@@ -1016,6 +1074,7 @@ fn run_async(
|
|||||||
fn feeder_loop(
|
fn feeder_loop(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
measure_decode: bool,
|
||||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||||
clock_offset: Arc<AtomicI64>,
|
clock_offset: Arc<AtomicI64>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
@@ -1026,13 +1085,16 @@ fn feeder_loop(
|
|||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if stats.enabled() {
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||||
|
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||||
|
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||||
|
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||||
|
let gap = client.note_frame_index(frame.frame_index);
|
||||||
|
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||||
|
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||||
|
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||||
|
if stats.enabled() || measure_decode {
|
||||||
let received_ns = now_realtime_ns();
|
let received_ns = now_realtime_ns();
|
||||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
|
||||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
|
||||||
let lat_us =
|
|
||||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
|
||||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
|
||||||
{
|
{
|
||||||
let mut g = in_flight
|
let mut g = in_flight
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1042,6 +1104,12 @@ fn feeder_loop(
|
|||||||
g.pop_front(); // stale — codec never echoed it back
|
g.pop_front(); // stale — codec never echoed it back
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if stats.enabled() {
|
||||||
|
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||||
|
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||||
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
|
.then_some((lat_ns / 1000) as u64);
|
||||||
|
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||||
if let Some(hostnet_us) = lat_us {
|
if let Some(hostnet_us) = lat_us {
|
||||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||||
@@ -1049,7 +1117,8 @@ fn feeder_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||||
|
{
|
||||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||||
stats.note_host_split(
|
stats.note_host_split(
|
||||||
t.host_us as u64,
|
t.host_us as u64,
|
||||||
@@ -1058,7 +1127,8 @@ fn feeder_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
|
}
|
||||||
|
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||||
break; // the decode loop is gone
|
break; // the decode loop is gone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1140,7 @@ fn feeder_loop(
|
|||||||
|
|
||||||
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||||
/// dropped on overflow (the caller then requests a keyframe).
|
/// dropped on overflow (the caller then requests a keyframe).
|
||||||
|
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
||||||
fn dispatch_event(
|
fn dispatch_event(
|
||||||
ev: DecodeEvent,
|
ev: DecodeEvent,
|
||||||
pending_aus: &mut VecDeque<Frame>,
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
@@ -1077,9 +1148,20 @@ fn dispatch_event(
|
|||||||
ready: &mut Vec<OutputReady>,
|
ready: &mut Vec<OutputReady>,
|
||||||
fmt_dirty: &mut bool,
|
fmt_dirty: &mut bool,
|
||||||
fatal: &mut bool,
|
fatal: &mut bool,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match ev {
|
match ev {
|
||||||
DecodeEvent::Au(f) => {
|
DecodeEvent::Au(f, gap) => {
|
||||||
|
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||||
|
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||||
|
if gap {
|
||||||
|
gate.arm(Instant::now());
|
||||||
|
}
|
||||||
|
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||||
|
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||||
|
recovery_flags.pop_front();
|
||||||
|
}
|
||||||
pending_aus.push_back(f);
|
pending_aus.push_back(f);
|
||||||
if pending_aus.len() > FRAME_PARK_CAP {
|
if pending_aus.len() > FRAME_PARK_CAP {
|
||||||
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||||
@@ -1100,6 +1182,10 @@ fn dispatch_event(
|
|||||||
DecodeEvent::Error { fatal: f } => {
|
DecodeEvent::Error { fatal: f } => {
|
||||||
if f {
|
if f {
|
||||||
*fatal = true;
|
*fatal = true;
|
||||||
|
} else {
|
||||||
|
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
||||||
|
// arm the freeze so the concealed output it recovers into is held off the screen.
|
||||||
|
gate.arm(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1250,8 @@ fn feed_ready(
|
|||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||||
fn present_ready(
|
fn present_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
ready: &mut Vec<OutputReady>,
|
ready: &mut Vec<OutputReady>,
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||||
@@ -1171,22 +1259,40 @@ fn present_ready(
|
|||||||
tracker: &DisplayTracker,
|
tracker: &DisplayTracker,
|
||||||
rendered: &mut u64,
|
rendered: &mut u64,
|
||||||
discarded: &mut u64,
|
discarded: &mut u64,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
) {
|
) {
|
||||||
if ready.is_empty() {
|
if ready.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if stats.enabled() {
|
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
||||||
|
// while visible) — both consume the receipt map, so enter for either.
|
||||||
|
if stats.enabled() || measure_decode {
|
||||||
let mut g = in_flight
|
let mut g = in_flight
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
for o in ready.iter() {
|
for o in ready.iter() {
|
||||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
note_decoded_pts(
|
||||||
|
client,
|
||||||
|
measure_decode,
|
||||||
|
stats,
|
||||||
|
&mut g,
|
||||||
|
clock_offset,
|
||||||
|
o.pts_us,
|
||||||
|
o.decoded_ns,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||||
|
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||||
|
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||||
|
let now = Instant::now();
|
||||||
let last = ready.len() - 1;
|
let last = ready.len() - 1;
|
||||||
let mut skipped: u64 = 0;
|
let mut skipped: u64 = 0;
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
let render = i == last;
|
let flags = take_flags(recovery_flags, o.pts_us);
|
||||||
|
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||||
|
let render = i == last && present;
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
Ok(()) if render => {
|
Ok(()) if render => {
|
||||||
*rendered += 1;
|
*rendered += 1;
|
||||||
@@ -1206,7 +1312,7 @@ fn present_ready(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||||
@@ -1395,6 +1501,8 @@ fn feed(
|
|||||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||||
fn drain(
|
fn drain(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
window: &NativeWindow,
|
window: &NativeWindow,
|
||||||
applied_ds: &mut Option<DataSpace>,
|
applied_ds: &mut Option<DataSpace>,
|
||||||
first_wait: Duration,
|
first_wait: Duration,
|
||||||
@@ -1402,22 +1510,42 @@ fn drain(
|
|||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
tracker: &DisplayTracker,
|
tracker: &DisplayTracker,
|
||||||
|
gate: &mut ReanchorGate,
|
||||||
|
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||||
) -> (u64, u64) {
|
) -> (u64, u64) {
|
||||||
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
|
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
||||||
|
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
||||||
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||||
|
let mut held_present = true;
|
||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
let mut wait = first_wait;
|
let mut wait = first_wait;
|
||||||
loop {
|
loop {
|
||||||
match codec.dequeue_output_buffer(wait) {
|
match codec.dequeue_output_buffer(wait) {
|
||||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||||
wait = Duration::ZERO; // only the first dequeue may block
|
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
||||||
let meta = if stats.enabled() {
|
wait = Duration::ZERO;
|
||||||
// The dequeue IS the sync loop's decoded-availability instant.
|
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
||||||
|
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
||||||
|
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
||||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||||
|
let flags = take_flags(recovery_flags, pts_us);
|
||||||
|
held_present =
|
||||||
|
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||||
|
let meta = if stats.enabled() || measure_decode {
|
||||||
|
// The dequeue IS the sync loop's decoded-availability instant.
|
||||||
let decoded_ns = now_realtime_ns();
|
let decoded_ns = now_realtime_ns();
|
||||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
note_decoded_pts(
|
||||||
Some((pts_us, decoded_ns))
|
client,
|
||||||
|
measure_decode,
|
||||||
|
stats,
|
||||||
|
in_flight,
|
||||||
|
clock_offset,
|
||||||
|
pts_us,
|
||||||
|
decoded_ns,
|
||||||
|
);
|
||||||
|
// The tracker's `display` stage is a HUD concern — park only when visible.
|
||||||
|
stats.enabled().then_some((pts_us, decoded_ns))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -1460,16 +1588,19 @@ fn drain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Present the newest ready frame, if any, and park its metadata for the render callback.
|
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
||||||
|
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
||||||
|
// on glass) and count it as a discard rather than a display.
|
||||||
let mut rendered = 0;
|
let mut rendered = 0;
|
||||||
if let Some((buf, meta)) = held {
|
if let Some((buf, meta)) = held {
|
||||||
match codec.release_output_buffer(buf, true) {
|
match codec.release_output_buffer(buf, held_present) {
|
||||||
Ok(()) => {
|
Ok(()) if held_present => {
|
||||||
rendered = 1;
|
rendered = 1;
|
||||||
if let Some((pts_us, decoded_ns)) = meta {
|
if let Some((pts_us, decoded_ns)) = meta {
|
||||||
tracker.note_rendered(pts_us, decoded_ns);
|
tracker.note_rendered(pts_us, decoded_ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1485,6 +1616,8 @@ fn drain(
|
|||||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||||
/// stamp (async loop).
|
/// stamp (async loop).
|
||||||
fn note_decoded_pts(
|
fn note_decoded_pts(
|
||||||
|
client: &NativeClient,
|
||||||
|
measure_decode: bool,
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
@@ -1503,13 +1636,45 @@ fn note_decoded_pts(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
|
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||||
// to < 1 µs — negligible against the ms-scale figures shown.
|
// Adaptive bitrate: the `decode` stage (received→decoded, single-clock local) IS the decoder-
|
||||||
|
// backlog signal — the only bottleneck the host-side network signals can't see (a fast LAN
|
||||||
|
// feeding a slower mobile decoder). Report it whenever the controller is armed, regardless of
|
||||||
|
// the HUD; `report_decode_us` is a cheap accumulate the pump windows.
|
||||||
|
if measure_decode {
|
||||||
|
if let Some(us) = decode_us {
|
||||||
|
client.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HUD histogram: only while the overlay is visible (a measure-only caller enters here for the
|
||||||
|
// ABR report alone). `end-to-end` = capture→decoded (skew-corrected) tiles the `decode` stage.
|
||||||
|
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time to
|
||||||
|
// < 1 µs — negligible against the ms-scale figures shown.
|
||||||
|
if stats.enabled() {
|
||||||
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
|
||||||
stats.note_decoded(e2e_us, decode_us);
|
stats.note_decoded(e2e_us, decode_us);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||||
|
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
||||||
|
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
||||||
|
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
||||||
|
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
||||||
|
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
||||||
|
fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
||||||
|
while let Some(&(p, f)) = map.front() {
|
||||||
|
if p > pts_us {
|
||||||
|
break; // future frame — leave it for its own output buffer
|
||||||
|
}
|
||||||
|
map.pop_front();
|
||||||
|
if p == pts_us {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||||
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
|||||||
const TAG_LED: u8 = 0x01;
|
const TAG_LED: u8 = 0x01;
|
||||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||||
const TAG_TRIGGER: u8 = 0x03;
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
|
const TAG_HID_RAW: u8 = 0x05;
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||||
/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
|
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||||
/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is
|
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
||||||
/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to
|
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
||||||
/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls
|
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
||||||
/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so
|
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
||||||
/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
|
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
||||||
|
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -46,14 +48,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||||
Ok((_pad, low, high, ttl)) => {
|
Ok((pad, low, high, ttl)) => {
|
||||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
||||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim.
|
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
||||||
|
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
||||||
let (lease_flag, ttl_bits) = match ttl {
|
let (lease_flag, ttl_bits) = match ttl {
|
||||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||||
None => (0, 0),
|
None => (0, 0),
|
||||||
};
|
};
|
||||||
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
|
(jlong::from(pad & 0xF) << 49)
|
||||||
|
| lease_flag
|
||||||
|
| ttl_bits
|
||||||
|
| (jlong::from(low) << 16)
|
||||||
|
| jlong::from(high)
|
||||||
}
|
}
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
}
|
}
|
||||||
@@ -61,10 +68,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||||
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
/// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the
|
||||||
/// Led → `[0x01][r][g][b]` (len 4)
|
/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
|
||||||
/// PlayerLeds → `[0x02][bits]` (len 2)
|
/// controller — multi-pad HID feedback):
|
||||||
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
/// Led → `[pad][0x01][r][g][b]` (len 5)
|
||||||
|
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||||
|
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||||
@@ -97,33 +106,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||||
|
|
||||||
|
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
|
||||||
let n = match ev {
|
let n = match ev {
|
||||||
HidOutput::Led { r, g, b, .. } => {
|
HidOutput::Led { pad, r, g, b } => {
|
||||||
if cap < 4 {
|
if cap < 5 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
out[0] = TAG_LED;
|
out[0] = pad;
|
||||||
out[1] = r;
|
out[1] = TAG_LED;
|
||||||
out[2] = g;
|
out[2] = r;
|
||||||
out[3] = b;
|
out[3] = g;
|
||||||
4
|
out[4] = b;
|
||||||
|
5
|
||||||
}
|
}
|
||||||
HidOutput::PlayerLeds { bits, .. } => {
|
HidOutput::PlayerLeds { pad, bits } => {
|
||||||
if cap < 2 {
|
if cap < 3 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
out[0] = TAG_PLAYER_LEDS;
|
out[0] = pad;
|
||||||
out[1] = bits;
|
out[1] = TAG_PLAYER_LEDS;
|
||||||
2
|
out[2] = bits;
|
||||||
|
3
|
||||||
}
|
}
|
||||||
HidOutput::Trigger { which, effect, .. } => {
|
HidOutput::Trigger { pad, which, effect } => {
|
||||||
let n = 2 + effect.len();
|
let n = 3 + effect.len();
|
||||||
if cap < n {
|
if cap < n {
|
||||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||||
}
|
}
|
||||||
out[0] = TAG_TRIGGER;
|
out[0] = pad;
|
||||||
out[1] = which;
|
out[1] = TAG_TRIGGER;
|
||||||
out[2..n].copy_from_slice(&effect);
|
out[2] = which;
|
||||||
|
out[3..n].copy_from_slice(&effect);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
HidOutput::TrackpadHaptic { .. } => {
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
@@ -131,6 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
// rumble already rides the universal 0xCA plane).
|
// rumble already rides the universal 0xCA plane).
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
HidOutput::HidRaw { pad, kind, data } => {
|
||||||
|
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
|
||||||
|
// the virtual pad; Kotlin replays it verbatim on the physical controller.
|
||||||
|
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
|
||||||
|
let n = 3 + data.len();
|
||||||
|
if cap < n {
|
||||||
|
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
|
||||||
|
}
|
||||||
|
out[0] = pad;
|
||||||
|
out[1] = TAG_HID_RAW;
|
||||||
|
out[2] = kind;
|
||||||
|
out[3..n].copy_from_slice(&data);
|
||||||
|
n
|
||||||
|
}
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,43 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||||
|
|
||||||
|
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
||||||
|
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
||||||
|
/// catch-all "wrong PIN, or the host isn't armed" (which blamed the PIN for dead network paths
|
||||||
|
/// — the moko0878-class support threads). The app runs one attempt at a time, so one slot
|
||||||
|
/// suffices; a stale token is harmless (it is taken immediately after the failed call).
|
||||||
|
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
|
||||||
|
|
||||||
|
/// Stable token for a failed pair/connect cause, matched by Kotlin (`ConnectErrors.kt`):
|
||||||
|
/// a typed host rejection yields its `RejectReason::as_str()` token ("not-armed", "denied",
|
||||||
|
/// "approval-timeout", …); transport-level causes map to "crypto" / "timeout" / "io" / "error".
|
||||||
|
fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||||
|
use punktfunk_core::error::PunktfunkError as E;
|
||||||
|
let token = match e {
|
||||||
|
E::Rejected(r) => r.as_str(),
|
||||||
|
E::Crypto => "crypto",
|
||||||
|
E::Timeout => "timeout",
|
||||||
|
E::Io(_) => "io",
|
||||||
|
_ => "error",
|
||||||
|
};
|
||||||
|
*LAST_ERROR.lock().unwrap() = token.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||||
|
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||||
|
/// handle / `""` fingerprint.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
) -> jni::sys::jstring {
|
||||||
|
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
||||||
|
match env.new_string(token) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => JObject::null().into_raw(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||||
@@ -185,6 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||||
|
note_error(&e);
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +356,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
|||||||
Ok(host_fp) => hex32(&host_fp),
|
Ok(host_fp) => hex32(&host_fp),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||||
|
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
|
||||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||||
|
note_error(&e);
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||||
|
|
||||||
use jni::objects::JObject;
|
use jni::objects::{JByteBuffer, JObject};
|
||||||
use jni::sys::{jboolean, jint, jlong};
|
use jni::sys::{jboolean, jint, jlong};
|
||||||
use jni::JNIEnv;
|
use jni::JNIEnv;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||||
|
|
||||||
use super::SessionHandle;
|
use super::SessionHandle;
|
||||||
|
|
||||||
@@ -145,13 +146,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||||
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
|
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||||
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
|
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||||
// in `code` and the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host
|
// stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_*
|
||||||
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
|
// bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id in `code` and
|
||||||
|
// the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host accumulates the
|
||||||
|
// incremental events per pad into a matching virtual device. The core input task folds these into
|
||||||
|
// the seq'd GamepadState snapshots (keyed on this same `flags` index) and owns the per-pad seq — so
|
||||||
|
// the only thing this layer must get right is the index. Wire contract: input.rs::gamepad. A single
|
||||||
|
// controller lands on index 0, so its wire is byte-identical to the old single-pad path.
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
|
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||||
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
|
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||||
|
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -159,21 +166,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
bit: jint,
|
bit: jint,
|
||||||
down: jboolean,
|
down: jboolean,
|
||||||
|
pad: jint,
|
||||||
) {
|
) {
|
||||||
// flags = 0: pad index 0 — single-pad model.
|
|
||||||
send_event(
|
send_event(
|
||||||
handle,
|
handle,
|
||||||
InputKind::GamepadButton,
|
InputKind::GamepadButton,
|
||||||
bit as u32,
|
bit as u32,
|
||||||
i32::from(down != 0),
|
i32::from(down != 0),
|
||||||
0,
|
0,
|
||||||
0,
|
pad as u32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
|
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||||
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or
|
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||||
/// trigger 0..255.
|
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -181,7 +188,92 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
|||||||
handle: jlong,
|
handle: jlong,
|
||||||
axis_id: jint,
|
axis_id: jint,
|
||||||
value: jint,
|
value: jint,
|
||||||
|
pad: jint,
|
||||||
) {
|
) {
|
||||||
// flags = 0: pad index 0 — single-pad model.
|
send_event(
|
||||||
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0);
|
handle,
|
||||||
|
InputKind::GamepadAxis,
|
||||||
|
axis_id as u32,
|
||||||
|
value,
|
||||||
|
0,
|
||||||
|
pad as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendGamepadArrival(handle, pref, pad)` — declare the controller KIND presented
|
||||||
|
/// on wire pad index `pad` so the host builds a matching virtual device (mixed types — pad 0 a
|
||||||
|
/// DualSense, pad 1 an Xbox pad). `pref`: the `GamepadPref` wire byte (rides `code`). `pad`: wire pad
|
||||||
|
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||||
|
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||||
|
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pref: jint,
|
||||||
|
pad: jint,
|
||||||
|
) {
|
||||||
|
send_event(
|
||||||
|
handle,
|
||||||
|
InputKind::GamepadArrival,
|
||||||
|
pref as u32,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pad as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||||
|
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||||
|
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||||
|
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jint,
|
||||||
|
) {
|
||||||
|
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPadHidReport(handle, pad, buf, len)` — one raw HID input report from a
|
||||||
|
/// client-captured controller (the as-is Steam Controller 2 passthrough), forwarded verbatim on
|
||||||
|
/// the rich-input plane (`RichInput::HidReport`, 0xCC). `buf` is a DIRECT ByteBuffer whose first
|
||||||
|
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||||
|
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||||
|
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||||
|
env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jint,
|
||||||
|
buf: JByteBuffer,
|
||||||
|
len: jint,
|
||||||
|
) {
|
||||||
|
if handle == 0 || len <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||||
|
Ok(p) if !p.is_null() => p,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
|
||||||
|
let mut data = [0u8; HID_REPORT_MAX];
|
||||||
|
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
|
||||||
|
// `n` is bounded by both the buffer capacity and the fixed wire body.
|
||||||
|
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let _ = h.client.send_rich_input(RichInput::HidReport {
|
||||||
|
pad: (pad as u32 & 0xF) as u8,
|
||||||
|
len: n as u8,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,22 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Custom keys merged into the auto-generated Info.plist (GENERATE_INFOPLIST_FILE=YES
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
supplies the rest). NSBonjourServices is required for NWBrowser to browse this
|
<true/>
|
||||||
service type on iOS/tvOS — without it the system blocks the browse and discovery
|
<key>GCSupportedGameControllers</key>
|
||||||
returns nothing. Kept OUT of the synchronized App/ + Sources/ groups so it isn't
|
<array>
|
||||||
auto-added as a bundle resource (which collides with Info.plist processing). -->
|
<dict>
|
||||||
|
<key>ProfileName</key>
|
||||||
|
<string>ExtendedGamepad</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>ProfileName</key>
|
||||||
|
<string>MicroGamepad</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_punktfunk._udp</string>
|
<string>_punktfunk._udp</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- Standard-algorithm crypto only (AES-GCM via the Rust core) — exempt from export
|
|
||||||
compliance, but the key must be declared or every TestFlight build stalls on the
|
|
||||||
compliance question. -->
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<!-- Allow CADisplayLink above 60 Hz on ProMotion iPhones: without this key the system
|
|
||||||
silently caps the link at 60 even when SessionPresenter asks for the stream's rate
|
|
||||||
via preferredFrameRateRange, so a 120 fps stream would present at half rate. -->
|
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,19 +14,11 @@
|
|||||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||||
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||||
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||||
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
|
||||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
gated by this (its App Sandbox network.client/server cover it), hence its separate file.
|
||||||
|
Approved and provisioned, so it's enabled here and PunktfunkConnection.wakeOnLANAvailable
|
||||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
is true on iOS/tvOS too. -->
|
||||||
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
|
||||||
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
|
||||||
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
|
||||||
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
|
||||||
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
|
||||||
file, no multicast entitlement needed). -->
|
|
||||||
<!--
|
|
||||||
<key>com.apple.developer.networking.multicast</key>
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
<true/>
|
<true/>
|
||||||
-->
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ let package = Package(
|
|||||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]),
|
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||||
|
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||||
|
.testTarget(
|
||||||
|
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
|
||||||
|
resources: [
|
||||||
|
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||||
|
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||||
|
// Vulkan box — see PyroWaveDecoderTests.swift).
|
||||||
|
.copy("PyroWaveFixtures")
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,13 @@
|
|||||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "PUNKTFUNK_BILINEAR_LUMA"
|
||||||
|
value = "1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct ContentView: View {
|
|||||||
case "h264": return PunktfunkConnection.codecH264
|
case "h264": return PunktfunkConnection.codecH264
|
||||||
case "hevc": return PunktfunkConnection.codecHEVC
|
case "hevc": return PunktfunkConnection.codecHEVC
|
||||||
case "av1": return PunktfunkConnection.codecAV1
|
case "av1": return PunktfunkConnection.codecAV1
|
||||||
|
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
||||||
default: return 0
|
default: return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,8 @@ struct ContentView: View {
|
|||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||||
|
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Whether the hosting window is native-fullscreen right now (reported by
|
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||||
@@ -86,6 +88,10 @@ struct ContentView: View {
|
|||||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||||
|
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||||
|
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||||
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
@@ -259,9 +265,26 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||||
// `home`, so it covers the whole wake→online→connect sequence).
|
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
// "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host
|
||||||
|
// turns out to be asleep.
|
||||||
|
homeBase.overlay {
|
||||||
|
ConnectOverlay(
|
||||||
|
connectingHostName: connectingOverlayName,
|
||||||
|
waker: waker,
|
||||||
|
gamepadUI: gamepadUIActive,
|
||||||
|
onCancelConnect: { model.disconnect() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil
|
||||||
|
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
|
||||||
|
/// takeover must not stack over it) and, of course, when idle or streaming.
|
||||||
|
private var connectingOverlayName: String? {
|
||||||
|
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
|
||||||
|
else { return nil }
|
||||||
|
return host.displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var homeBase: some View {
|
@ViewBuilder private var homeBase: some View {
|
||||||
@@ -327,12 +350,25 @@ struct ContentView: View {
|
|||||||
}()
|
}()
|
||||||
return ZStack {
|
return ZStack {
|
||||||
stream(captureEnabled: pendingFingerprint == nil)
|
stream(captureEnabled: pendingFingerprint == nil)
|
||||||
.blur(radius: pendingFingerprint != nil ? 32 : 0)
|
// Blur the live stream during the trust prompt (heavy) and during a resize (lighter
|
||||||
|
// — the deliberate "hold on" while the host rebuilds its pipeline and the decoder
|
||||||
|
// re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps
|
||||||
|
// as before (its own overlay handles the transition).
|
||||||
|
.blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||||
.overlay {
|
.overlay {
|
||||||
if pendingFingerprint != nil {
|
if pendingFingerprint != nil {
|
||||||
Color.black.opacity(0.45)
|
Color.black.opacity(0.45)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||||
|
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||||
|
// steering and the next click still reaches the stream.
|
||||||
|
.overlay {
|
||||||
|
if pendingFingerprint == nil {
|
||||||
|
ResizeIndicatorView(active: model.resizing)
|
||||||
|
}
|
||||||
|
}
|
||||||
if let fp = pendingFingerprint {
|
if let fp = pendingFingerprint {
|
||||||
TrustCardView(
|
TrustCardView(
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -410,6 +446,16 @@ struct ContentView: View {
|
|||||||
onSessionEnd: { [weak model] in
|
onSessionEnd: { [weak model] in
|
||||||
Task { @MainActor in model?.sessionEnded() }
|
Task { @MainActor in model?.sessionEnded() }
|
||||||
},
|
},
|
||||||
|
// Resize overlay START — the follower is main-actor, so this drives the blur
|
||||||
|
// + spinner synchronously the instant the window differs from the live mode.
|
||||||
|
onResizeTarget: { [weak model] w, h in
|
||||||
|
model?.resizeTargeted(width: w, height: h)
|
||||||
|
},
|
||||||
|
// Resize overlay END — the coded dims of each new-mode IDR, reported from the
|
||||||
|
// decode pump thread; hop to the main actor to clear the overlay.
|
||||||
|
onDecodedSize: { [weak model] w, h in
|
||||||
|
Task { @MainActor in model?.resizeDecoded(width: w, height: h) }
|
||||||
|
},
|
||||||
endToEndMeter: model.endToEnd,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage
|
displayMeter: model.displayStage
|
||||||
@@ -544,7 +590,8 @@ struct ContentView: View {
|
|||||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
|
||||||
|
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
discovery.start() // so the wake-wait can observe it reappear
|
discovery.start() // so the wake-wait can observe it reappear
|
||||||
startSessionDirect(
|
startSessionDirect(
|
||||||
host, launchID: launchID, allowTofu: allowTofu,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
@@ -601,7 +648,9 @@ struct ContentView: View {
|
|||||||
private func prepareWake(for host: StoredHost) {
|
private func prepareWake(for host: StoredHost) {
|
||||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||||
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||||
|
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||||
|
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||||
let macs = host.wakeMacs
|
let macs = host.wakeMacs
|
||||||
let ip = host.address
|
let ip = host.address
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// The unified "getting you connected" overlay — one look for BOTH phases of reaching a host, so the
|
||||||
|
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||||
|
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
|
||||||
|
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
|
||||||
|
//
|
||||||
|
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
|
||||||
|
// so a host that takes a beat to answer no longer looks like nothing happened.
|
||||||
|
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
|
||||||
|
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
|
||||||
|
// timeout.
|
||||||
|
//
|
||||||
|
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover — the
|
||||||
|
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
|
||||||
|
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
|
||||||
|
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
|
||||||
|
// looked out of place there.
|
||||||
|
//
|
||||||
|
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
|
||||||
|
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
|
||||||
|
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectOverlay: View {
|
||||||
|
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
|
||||||
|
/// passes nil here). Drives the "Connecting…" phase.
|
||||||
|
let connectingHostName: String?
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
/// The console launcher is up → full-screen aurora takeover; otherwise the default UI's Liquid
|
||||||
|
/// Glass modal.
|
||||||
|
var gamepadUI: Bool
|
||||||
|
/// Cancel a dial in flight — tears down the (uncancelable) connect and returns the UI; the late
|
||||||
|
/// result is discarded by SessionModel's connect guard.
|
||||||
|
var onCancelConnect: () -> Void
|
||||||
|
|
||||||
|
private enum Phase {
|
||||||
|
case connecting(name: String)
|
||||||
|
case waking(HostWaker.Waking)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waking takes precedence — it only ever exists after a dial has already failed, so a stray
|
||||||
|
/// overlap can't strand the "Connecting…" phase over a wake in progress.
|
||||||
|
private var phase: Phase? {
|
||||||
|
if let w = waker.waking { return .waking(w) }
|
||||||
|
if let name = connectingHostName { return .connecting(name: name) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let phase {
|
||||||
|
ZStack {
|
||||||
|
if gamepadUI {
|
||||||
|
// Console: an opaque, living aurora over everything.
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
GamepadScreenBackground().ignoresSafeArea()
|
||||||
|
Color.clear.contentShape(Rectangle()).onTapGesture {}
|
||||||
|
content(phase).padding(40).frame(maxWidth: 460)
|
||||||
|
} else {
|
||||||
|
// Default UI: a Liquid Glass modal over a dim scrim.
|
||||||
|
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
|
||||||
|
.contentShape(Rectangle()).onTapGesture {}
|
||||||
|
content(phase)
|
||||||
|
.padding(28)
|
||||||
|
.frame(maxWidth: 380)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.transition(.opacity)
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
||||||
|
// The takeover carries larger type than the compact modal.
|
||||||
|
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
||||||
|
let bodySize: CGFloat = gamepadUI ? 14 : 13
|
||||||
|
VStack(spacing: gamepadUI ? 16 : 14) {
|
||||||
|
switch phase {
|
||||||
|
case .connecting(let name):
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Connecting to \(name)")
|
||||||
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("Establishing a secure connection…")
|
||||||
|
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
||||||
|
case .waking(let w) where w.timedOut:
|
||||||
|
Image(systemName: "moon.zzz.fill")
|
||||||
|
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
||||||
|
Text("\(w.hostName) didn't wake")
|
||||||
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("It may still be booting, or it's powered off / off this network.")
|
||||||
|
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||||
|
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
case .waking(let w):
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Waking \(w.hostName)…")
|
||||||
|
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("Waiting for it to come online · \(w.seconds)s")
|
||||||
|
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
||||||
|
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
||||||
|
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||||
|
.buttonStyle(.bordered).padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// Controller binding for the overlay: B cancels whatever's in flight (a dial or the wake wait); A
|
||||||
|
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
|
||||||
|
/// correct across the Connecting ↔ Waking handoff without the view re-mounting. A zero-size backing
|
||||||
|
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
|
||||||
|
/// overlay is up, so nothing else is consuming the pad).
|
||||||
|
private struct ConnectControllerInput: View {
|
||||||
|
@ObservedObject var waker: HostWaker
|
||||||
|
var onCancelConnect: () -> Void
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
|
||||||
|
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
.onDisappear { input.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -65,6 +65,9 @@ struct GamepadHomeView: View {
|
|||||||
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||||
/// Settings "Game library" toggle opts out).
|
/// Settings "Game library" toggle opts out).
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
|
/// Auto-wake on connect (default ON) — when off, activating an offline host just dials (no wake),
|
||||||
|
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
|
||||||
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
@@ -192,9 +195,12 @@ struct GamepadHomeView: View {
|
|||||||
onActivate: { $0.activate() },
|
onActivate: { $0.activate() },
|
||||||
onSecondary: { openLibraryForSelected() },
|
onSecondary: { openLibraryForSelected() },
|
||||||
onTertiary: { showSettings = true },
|
onTertiary: { showSettings = true },
|
||||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||||
|
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
|
||||||
|
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||||
|
&& waker.waking == nil && model.phase != .connecting
|
||||||
) { tile in
|
) { tile in
|
||||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||||
}
|
}
|
||||||
@@ -256,7 +262,7 @@ struct GamepadHomeView: View {
|
|||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||||
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||||
&& !host.wakeMacs.isEmpty,
|
&& !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
|
||||||
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
|
||||||
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
|
||||||
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
|
||||||
|
|
||||||
import PunktfunkKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct WakeOverlay: View {
|
|
||||||
@ObservedObject var waker: HostWaker
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let w = waker.waking {
|
|
||||||
ZStack {
|
|
||||||
// Dim + swallow input to the home behind it.
|
|
||||||
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {}
|
|
||||||
card(w)
|
|
||||||
.frame(maxWidth: 380)
|
|
||||||
.padding(28)
|
|
||||||
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
||||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
|
||||||
.padding(40)
|
|
||||||
}
|
|
||||||
.environment(\.colorScheme, .dark)
|
|
||||||
.transition(.opacity)
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
.background { WakeControllerInput(waker: waker) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
if w.timedOut {
|
|
||||||
Image(systemName: "moon.zzz.fill")
|
|
||||||
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
|
||||||
Text("\(w.hostName) didn't wake")
|
|
||||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
||||||
Text("It may still be booting, or it's powered off / off this network.")
|
|
||||||
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
|
||||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
|
||||||
}
|
|
||||||
.padding(.top, 6)
|
|
||||||
} else {
|
|
||||||
ProgressView().controlSize(.large).tint(.white)
|
|
||||||
Text("Waking \(w.hostName)…")
|
|
||||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
|
||||||
Text("Waiting for it to come online · \(w.seconds)s")
|
|
||||||
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
|
||||||
.monospacedDigit()
|
|
||||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
|
||||||
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
|
||||||
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
|
||||||
private struct WakeControllerInput: View {
|
|
||||||
@ObservedObject var waker: HostWaker
|
|
||||||
@State private var input = GamepadMenuInput(manager: .shared)
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Color.clear
|
|
||||||
.onAppear {
|
|
||||||
input.onBack = { waker.cancel() }
|
|
||||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
|
||||||
input.start()
|
|
||||||
}
|
|
||||||
.onDisappear { input.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -49,8 +49,24 @@ enum ShotScenes {
|
|||||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||||
AnyView(ShotGamepadAddHost())
|
AnyView(ShotGamepadAddHost())
|
||||||
},
|
},
|
||||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||||
AnyView(ShotWaking())
|
AnyView(ShotConnect(kind: .connecting))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .waking))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .timedOut))
|
||||||
|
},
|
||||||
|
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
|
||||||
|
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
|
||||||
|
},
|
||||||
|
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
#endif
|
#endif
|
||||||
@@ -137,23 +153,53 @@ private struct ShotGamepadAddHost: View {
|
|||||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ShotWaking: View {
|
/// The unified connect overlay (the real `ConnectOverlay`) in each phase — instant "Connecting…"
|
||||||
|
/// feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
|
||||||
|
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
|
||||||
|
/// modal over the touch host grid.
|
||||||
|
private struct ShotConnect: View {
|
||||||
|
enum Kind { case connecting, waking, timedOut }
|
||||||
|
let kind: Kind
|
||||||
|
var gamepadUI = true
|
||||||
|
|
||||||
@StateObject private var store = ShotMock.hostStore()
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = HostDiscovery()
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GamepadHomeView(
|
backdrop
|
||||||
store: store, model: model, discovery: discovery,
|
.overlay {
|
||||||
libraryTarget: .constant(nil), waker: waker,
|
ConnectOverlay(
|
||||||
connect: { _ in }, connectDiscovered: { _ in }
|
connectingHostName: kind == .connecting ? "Battlestation" : nil,
|
||||||
)
|
waker: waker,
|
||||||
.overlay { WakeOverlay(waker: waker) }
|
gamepadUI: gamepadUI,
|
||||||
|
onCancelConnect: {})
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
switch kind {
|
||||||
|
case .connecting:
|
||||||
|
break
|
||||||
|
case .waking:
|
||||||
waker.debugSet(.init(
|
waker.debugSet(.init(
|
||||||
hostID: store.hosts.first?.id ?? UUID(),
|
hostID: store.hosts.first?.id ?? UUID(),
|
||||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||||
|
case .timedOut:
|
||||||
|
waker.debugSet(.init(
|
||||||
|
hostID: store.hosts.first?.id ?? UUID(),
|
||||||
|
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var backdrop: some View {
|
||||||
|
if gamepadUI {
|
||||||
|
GamepadHomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
libraryTarget: .constant(nil), waker: waker,
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in })
|
||||||
|
} else {
|
||||||
|
ShotHome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
|
|||||||
var timedOut = false
|
var timedOut = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// nil = idle; non-nil drives `WakeOverlay`.
|
/// nil = idle; non-nil drives the "Waking…" phase of `ConnectOverlay`.
|
||||||
@Published private(set) var waking: Waking?
|
@Published private(set) var waking: Waking?
|
||||||
|
|
||||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// The resize overlay (design/midstream-resolution-resize.md — client resize UX). A Match-window
|
||||||
|
// resize renegotiates the host's virtual display + encoder and re-inits the local VideoToolbox
|
||||||
|
// decoder on the first new-mode IDR — an unavoidable sub-second gap where the last frame lingers,
|
||||||
|
// briefly freezes, or the picture pops to the new geometry. Rather than let that read as a stutter,
|
||||||
|
// we make it DELIBERATE: the caller blurs the live stream and this centered spinner + caption
|
||||||
|
// acknowledges the transition. It clears the instant a frame at the requested size decodes (the
|
||||||
|
// `onDecodedSize` END signal) or on the follower's safety timeout — see `SessionModel.resizing`.
|
||||||
|
//
|
||||||
|
// Floating overlay, never a hit-test target: input keeps flowing to the stream underneath so a
|
||||||
|
// resize the user triggers by dragging the window never swallows their next click.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ResizeIndicatorView: View {
|
||||||
|
/// Mirrors `SessionModel.resizing`; the fade in/out is driven off this.
|
||||||
|
let active: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if active {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.large).tint(.white)
|
||||||
|
Text("Resizing…")
|
||||||
|
.font(.geist(15, .medium, relativeTo: .callout))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark) // the spinner + glass read over any frame
|
||||||
|
.animation(.easeInOut(duration: 0.22), value: active)
|
||||||
|
.allowsHitTesting(false) // the stream keeps receiving input the whole time
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,16 @@ final class SessionModel: ObservableObject {
|
|||||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||||
@Published var mouseCaptured = false
|
@Published var mouseCaptured = false
|
||||||
|
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||||
|
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||||
|
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||||
|
/// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in
|
||||||
|
/// `ResizeIndicator`; this mirrors its `active` for SwiftUI.
|
||||||
|
@Published private(set) var resizing = false
|
||||||
|
/// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump,
|
||||||
|
/// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a
|
||||||
|
/// differently-sized frame. Ticked from the 1 Hz stats timer.
|
||||||
|
private var resizeIndicator = ResizeIndicator()
|
||||||
|
|
||||||
let meter = FrameMeter()
|
let meter = FrameMeter()
|
||||||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
||||||
@@ -229,6 +239,18 @@ final class SessionModel: ObservableObject {
|
|||||||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||||
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
||||||
|
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
||||||
|
// advertises the bit and prefers it — the host never auto-selects it, and the
|
||||||
|
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
||||||
|
// every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
|
||||||
|
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
||||||
|
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
||||||
|
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||||
|
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||||
|
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
||||||
|
| PunktfunkConnection.videoCapHDR
|
||||||
|
| PunktfunkConnection.videoCap444)
|
||||||
|
}
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
@@ -274,10 +296,15 @@ final class SessionModel: ObservableObject {
|
|||||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||||
+ "Pair with its PIN before streaming."
|
+ "Pair with its PIN before streaming."
|
||||||
}
|
}
|
||||||
case .failure:
|
case .failure(let error):
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
if let onUnreachable, !requestAccess {
|
if case PunktfunkClientError.rejected(let rejection) = error {
|
||||||
|
// The host answered and stated its reason (declined / approval timed
|
||||||
|
// out / busy / versions differ) — show that, and never wake-retry a
|
||||||
|
// host that is demonstrably awake.
|
||||||
|
self.errorMessage = "\(host.displayName): \(rejection.userMessage)"
|
||||||
|
} else if let onUnreachable, !requestAccess {
|
||||||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||||
// own overlay explains what's happening.
|
// own overlay explains what's happening.
|
||||||
onUnreachable()
|
onUnreachable()
|
||||||
@@ -364,6 +391,8 @@ final class SessionModel: ObservableObject {
|
|||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
mouseCaptured = false
|
||||||
|
resizing = false
|
||||||
|
resizeIndicator = ResizeIndicator() // no stale target/timer into the next session
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called (via the main actor) when the pump hits end-of-session.
|
/// Called (via the main actor) when the pump hits end-of-session.
|
||||||
@@ -374,6 +403,23 @@ final class SessionModel: ObservableObject {
|
|||||||
errorMessage = "Session ended by \(name)."
|
errorMessage = "Session ended by \(name)."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the
|
||||||
|
/// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is
|
||||||
|
/// imminent. Show the blur+spinner immediately, before the debounced request even leaves.
|
||||||
|
func resizeTargeted(width: UInt32, height: UInt32) {
|
||||||
|
resizeIndicator.steering(
|
||||||
|
width: width, height: height, now: Date().timeIntervalSinceReferenceDate)
|
||||||
|
resizing = resizeIndicator.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize overlay END (main actor — hopped from the decode pump's `onDecodedSize`): a new-mode
|
||||||
|
/// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're
|
||||||
|
/// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op).
|
||||||
|
func resizeDecoded(width: Int, height: Int) {
|
||||||
|
resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0)))
|
||||||
|
resizing = resizeIndicator.active
|
||||||
|
}
|
||||||
|
|
||||||
private func beginStreaming() {
|
private func beginStreaming() {
|
||||||
guard let conn = connection else { return }
|
guard let conn = connection else { return }
|
||||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||||
@@ -390,9 +436,10 @@ final class SessionModel: ObservableObject {
|
|||||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||||
self.audio = audio
|
self.audio = audio
|
||||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||||
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
|
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||||
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||||
|
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||||
// forwarded during the trust prompt.
|
// forwarded during the trust prompt.
|
||||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||||
@@ -417,6 +464,11 @@ final class SessionModel: ObservableObject {
|
|||||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
// Resize-overlay safety net: clear a stuck overlay when a targeted size never
|
||||||
|
// decodes (a rejected/capped switch). The decoded-frame END clears it promptly on
|
||||||
|
// success; this only fires after the timeout.
|
||||||
|
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
|
||||||
|
self.resizing = self.resizeIndicator.active
|
||||||
let (frames, bytes, total) = self.meter.drain()
|
let (frames, bytes, total) = self.meter.drain()
|
||||||
self.fps = frames
|
self.fps = frames
|
||||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
#if os(iOS)
|
||||||
|
import CoreHaptics
|
||||||
|
#endif
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -36,7 +39,11 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||||
|
#if os(iOS)
|
||||||
|
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||||
|
#endif
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -229,7 +236,7 @@ struct GamepadSettingsView: View {
|
|||||||
.map { (label: "\($0) Hz", tag: $0) }
|
.map { (label: "\($0) Hz", tag: $0) }
|
||||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||||
return [
|
var list: [Row] = [
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||||
label: "Resolution",
|
label: "Resolution",
|
||||||
@@ -258,6 +265,11 @@ struct GamepadSettingsView: View {
|
|||||||
+ "available on the host.",
|
+ "available on the host.",
|
||||||
options: SettingsOptions.compositors, current: compositor
|
options: SettingsOptions.compositors, current: compositor
|
||||||
) { compositor = $0 },
|
) { compositor = $0 },
|
||||||
|
toggleRow(
|
||||||
|
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||||||
|
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||||
|
+ "streaming. Off connects straight through.",
|
||||||
|
value: $autoWakeEnabled),
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||||
@@ -323,6 +335,23 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
value: $gamepadUIEnabled),
|
||||||
]
|
]
|
||||||
|
#if os(iOS)
|
||||||
|
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||||
|
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||||
|
// practice: hidden where the device itself can't play haptics (iPad).
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||||
|
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||||
|
list.insert(
|
||||||
|
toggleRow(
|
||||||
|
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||||
|
label: "Rumble on this iPhone",
|
||||||
|
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||||
|
+ "for clip-on pads without rumble motors.",
|
||||||
|
value: $rumbleOnDevice),
|
||||||
|
at: at + 1)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ enum SettingsOptions {
|
|||||||
if AV1.hardwareDecodeSupported {
|
if AV1.hardwareDecodeSupported {
|
||||||
options.insert(("AV1", "av1"), at: 2)
|
options.insert(("AV1", "av1"), at: 2)
|
||||||
}
|
}
|
||||||
|
// PyroWave is the opt-in wired-LAN low-latency codec (100–400 Mbps all-intra wavelet,
|
||||||
|
// 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when
|
||||||
|
// the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the
|
||||||
|
// host could never emit it.
|
||||||
|
if MetalWaveletDecoder.supported {
|
||||||
|
options.append(("PyroWave (wired LAN)", "pyrowave"))
|
||||||
|
}
|
||||||
return options
|
return options
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import CoreHaptics
|
||||||
|
#endif
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -13,6 +16,11 @@ extension SettingsView {
|
|||||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||||
@ViewBuilder var streamModeSection: some View {
|
@ViewBuilder var streamModeSection: some View {
|
||||||
Section {
|
Section {
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||||
|
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||||
|
Toggle("Match window", isOn: $matchWindow)
|
||||||
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
iosResolutionWheel
|
iosResolutionWheel
|
||||||
iosRefreshRows
|
iosRefreshRows
|
||||||
@@ -35,8 +43,12 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Stream mode")
|
Text("Stream mode")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The host creates a virtual output at exactly this mode — "
|
Text(matchWindow
|
||||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
? "The stream follows this window — the host resizes its virtual output to match "
|
||||||
|
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||||
|
+ "\(Self.bitrateFooter)"
|
||||||
|
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||||
|
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -286,6 +298,24 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||||
|
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||||
|
@ViewBuilder var wakeSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Wake-on-LAN")
|
||||||
|
} footer: {
|
||||||
|
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||||
|
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||||
|
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||||
|
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||||
|
+ "way.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var windowSection: some View {
|
@ViewBuilder var windowSection: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Section {
|
Section {
|
||||||
@@ -444,6 +474,12 @@ extension SettingsView {
|
|||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
#endif
|
#endif
|
||||||
@@ -460,6 +496,11 @@ extension SettingsView {
|
|||||||
// for its own footer and has no such toggle to describe.
|
// for its own footer and has no such toggle to describe.
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
|
#if os(iOS)
|
||||||
|
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
Text(Self.deviceRumbleFooter)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Text(Self.gamepadUIFooter)
|
Text(Self.gamepadUIFooter)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ extension SettingsView {
|
|||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "Applies from the next session."
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let deviceRumbleFooter =
|
||||||
|
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
||||||
|
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
||||||
|
+ "from the next session."
|
||||||
|
#endif
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
static let gamepadUIFooter =
|
static let gamepadUIFooter =
|
||||||
"When a controller connects, the host list and library switch to a controller-"
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
@@ -133,8 +140,10 @@ extension SettingsView {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if gamepads.active?.id == controller.id {
|
// Every forwarded controller is surfaced (not just the primary `active`) with its
|
||||||
Text("In use")
|
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
|
||||||
|
if let pad = gamepads.padIndex(for: controller) {
|
||||||
|
Text("Player \(pad + 1)")
|
||||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||||
|
// Opt-in (default OFF): the explicit mode below is used and never auto-resized. When ON, a
|
||||||
|
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||||
|
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||||
|
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@@ -44,12 +48,14 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||||
|
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||||
// General on iPad (a two-column layout should never open with an empty detail).
|
// General on iPad (a two-column layout should never open with an empty detail).
|
||||||
@@ -105,6 +111,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
compositorSection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
@@ -234,6 +241,7 @@ struct SettingsView: View {
|
|||||||
streamModeSection
|
streamModeSection
|
||||||
pointerSection
|
pointerSection
|
||||||
compositorSection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
@@ -304,6 +312,10 @@ struct SettingsView: View {
|
|||||||
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var autoWakeEnabledTag: Binding<String> {
|
||||||
|
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||||
|
}
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -343,9 +355,13 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Auto-wake on connect",
|
||||||
|
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||||
Text("The host creates a virtual output at exactly this mode — native "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
+ "is honored only if available on the host.")
|
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||||
|
+ "sleeping saved host and waits for it before streaming.")
|
||||||
.font(.geist(20, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -212,14 +212,18 @@ struct PairSheet: View {
|
|||||||
case .failure(PunktfunkClientError.wrongPIN):
|
case .failure(PunktfunkClientError.wrongPIN):
|
||||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||||
+ "and try again."
|
+ "and try again."
|
||||||
|
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||||
|
// The host answered and said why (not armed / rate-limited / armed for
|
||||||
|
// another device) — show that instead of the guessing-game fallback.
|
||||||
|
errorText = rejection.userMessage
|
||||||
case .failure(is ClientIdentityStore.IdentityError):
|
case .failure(is ClientIdentityStore.IdentityError):
|
||||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||||
+ "pairing would not survive a relaunch. Unlock the login "
|
+ "pairing would not survive a relaunch. Unlock the login "
|
||||||
+ "keychain and try again."
|
+ "keychain and try again."
|
||||||
case .failure:
|
case .failure:
|
||||||
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||||
+ "(web console → Pairing), and not mid-session? Retries are "
|
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||||
+ "rate-limited to one per 2 seconds."
|
+ "isolation)?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ public func pair(
|
|||||||
switch rc {
|
switch rc {
|
||||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||||
default: throw PunktfunkClientError.status(rc)
|
default:
|
||||||
|
// A typed host rejection (pairing not armed / rate-limited / armed for another
|
||||||
|
// device) carries its own reason — never report it as a bad PIN or dead network.
|
||||||
|
if let rejection = HostRejection(status: rc) {
|
||||||
|
throw PunktfunkClientError.rejected(rejection)
|
||||||
|
}
|
||||||
|
throw PunktfunkClientError.status(rc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ public extension PunktfunkInputEvent {
|
|||||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declare a pad's controller KIND (`InputKind::GamepadArrival`): `pref` is the
|
||||||
|
/// `GamepadType` wire byte (Auto=0, Xbox360=1, DualSense=2, XboxOne=3, DualShock4=4,
|
||||||
|
/// SteamController=5, SteamDeck=6), `pad` the wire index. Sent once when a controller slot
|
||||||
|
/// opens — BEFORE that pad's first input — so the host builds a matching virtual device and a
|
||||||
|
/// session can mix types (pad 0 a DualSense, pad 1 an Xbox pad). The core re-sends it a few
|
||||||
|
/// times against datagram loss and folds per-pad state behind it; a host that predates the tag
|
||||||
|
/// ignores it and uses the session-default kind from the handshake. Idempotent on the host.
|
||||||
|
static func gamepadArrival(pref: UInt32, pad: UInt32) -> PunktfunkInputEvent {
|
||||||
|
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue, code: pref, x: 0, y: 0, flags: pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pad disconnected (`InputKind::GamepadRemove`): `flags` = pad index. The client sends the
|
||||||
|
/// bare index; the core stamps the per-pad removal seq (`encode_gamepad_remove`) in the shared
|
||||||
|
/// snapshot seq space and arms a loss-resistant re-send burst, so the host tears the pad's
|
||||||
|
/// virtual device down and no reordered snapshot can resurrect it. A host that predates the tag
|
||||||
|
/// ignores it (the pad then lingers until session end — the pre-existing behaviour).
|
||||||
|
static func gamepadRemove(pad: UInt32) -> PunktfunkInputEvent {
|
||||||
|
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue, code: 0, x: 0, y: 0, flags: pad)
|
||||||
|
}
|
||||||
|
|
||||||
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
||||||
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
||||||
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
||||||
|
|||||||
@@ -59,6 +59,68 @@ public enum PunktfunkClientError: Error {
|
|||||||
case wrongPIN
|
case wrongPIN
|
||||||
case closed
|
case closed
|
||||||
case status(Int32)
|
case status(Int32)
|
||||||
|
/// The host deliberately turned the attempt away and said why (its typed QUIC
|
||||||
|
/// application close) — distinct from `.connectFailed` (unreachable/timeout) so the UI
|
||||||
|
/// can show the stated reason instead of blaming the network.
|
||||||
|
case rejected(HostRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a host turned a connect/pair attempt away — decoded from the
|
||||||
|
/// `PUNKTFUNK_STATUS_REJECTED_*` block. Lets the UI say "approve the request on the host"
|
||||||
|
/// or "pairing isn't armed" instead of a generic "could not connect".
|
||||||
|
public enum HostRejection: Sendable {
|
||||||
|
case pairingNotArmed
|
||||||
|
case pairingBoundToOtherDevice
|
||||||
|
case pairingRateLimited
|
||||||
|
case identityRequired
|
||||||
|
case denied
|
||||||
|
case approvalTimeout
|
||||||
|
case superseded
|
||||||
|
case wireVersionMismatch
|
||||||
|
case busy
|
||||||
|
|
||||||
|
init?(status: Int32) {
|
||||||
|
switch status {
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_NOT_ARMED.rawValue: self = .pairingNotArmed
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_BOUND_OTHER.rawValue: self = .pairingBoundToOtherDevice
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_RATE_LIMITED.rawValue: self = .pairingRateLimited
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_IDENTITY_REQUIRED.rawValue: self = .identityRequired
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_DENIED.rawValue: self = .denied
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_APPROVAL_TIMEOUT.rawValue: self = .approvalTimeout
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch
|
||||||
|
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing sentence — wording shared with the desktop clients.
|
||||||
|
public var userMessage: String {
|
||||||
|
switch self {
|
||||||
|
case .pairingNotArmed:
|
||||||
|
return "Pairing isn't armed on the host — arm it on the host's Pairing page, "
|
||||||
|
+ "then try again."
|
||||||
|
case .pairingBoundToOtherDevice:
|
||||||
|
return "The host's pairing window is armed for a different device — arm it "
|
||||||
|
+ "for this one."
|
||||||
|
case .pairingRateLimited:
|
||||||
|
return "Too many pairing attempts — wait a couple of seconds and try again."
|
||||||
|
case .identityRequired:
|
||||||
|
return "The host requires pairing — pair this device (PIN or request access) first."
|
||||||
|
case .denied:
|
||||||
|
return "The host declined this device's request."
|
||||||
|
case .approvalTimeout:
|
||||||
|
return "Nobody approved the request on the host in time — approve this device "
|
||||||
|
+ "in the host's console or web UI, then request access again."
|
||||||
|
case .superseded:
|
||||||
|
return "A newer request from this device replaced this one — approve the "
|
||||||
|
+ "latest request on the host."
|
||||||
|
case .wireVersionMismatch:
|
||||||
|
return "Client and host versions don't match — update both to the same release."
|
||||||
|
case .busy:
|
||||||
|
return "The host is busy with another session."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
||||||
@@ -70,19 +132,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
|||||||
public extension PunktfunkConnection {
|
public extension PunktfunkConnection {
|
||||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||||
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||||
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
/// `com.apple.developer.networking.multicast` entitlement — now approved and enabled (see
|
||||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
/// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
|
||||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
/// switch every call site gates on, should a future build ever need to disable it.
|
||||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
static var wakeOnLANAvailable: Bool { true }
|
||||||
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
|
||||||
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
|
||||||
static var wakeOnLANAvailable: Bool {
|
|
||||||
#if os(macOS)
|
|
||||||
return true
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||||
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||||
@@ -197,6 +250,19 @@ public final class PunktfunkConnection {
|
|||||||
// exist so the resolved type round-trips and name parsing matches the host.
|
// exist so the resolved type round-trips and name parsing matches the host.
|
||||||
case steamController = 5
|
case steamController = 5
|
||||||
case steamDeck = 6
|
case steamDeck = 6
|
||||||
|
/// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn
|
||||||
|
/// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own
|
||||||
|
/// product category; paddle CAPTURE is still gated on G22, but the declared identity +
|
||||||
|
/// rich planes match the physical pad.
|
||||||
|
case dualSenseEdge = 7
|
||||||
|
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
|
||||||
|
/// glyphs + positional layout on the host side.
|
||||||
|
case switchPro = 8
|
||||||
|
/// New Steam Controller (2026, `28DE:1302`), passed through as-is on Linux hosts (raw
|
||||||
|
/// report mirroring; Steam Input is the consumer). Parity only on Apple — GameController
|
||||||
|
/// never surfaces the raw Valve device, so the client can't capture one; exists so the
|
||||||
|
/// resolved type round-trips and name parsing matches the host.
|
||||||
|
case steamController2 = 9
|
||||||
|
|
||||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||||
/// `GamepadPref::from_name`.
|
/// `GamepadPref::from_name`.
|
||||||
@@ -209,6 +275,11 @@ public final class PunktfunkConnection {
|
|||||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||||
|
case "steamcontroller2", "steam-controller-2", "steamcon2", "sc2", "ibex":
|
||||||
|
self = .steamController2
|
||||||
|
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
|
||||||
|
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
|
||||||
|
self = .switchPro
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,9 +337,15 @@ public final class PunktfunkConnection {
|
|||||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||||
|
|
||||||
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
||||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1, `8` = PyroWave (only when this
|
||||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
/// client opted in). Build the decoder from THIS. The resolved value honors the client's
|
||||||
|
/// `preferredCodec` when the host could emit it.
|
||||||
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||||
|
|
||||||
|
/// The session's negotiated wire shard payload (`Welcome.shard_payload`, bytes) — the
|
||||||
|
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
|
||||||
|
/// never need it.
|
||||||
|
public private(set) var shardPayload: UInt32 = 1408
|
||||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||||
@@ -310,6 +387,10 @@ public final class PunktfunkConnection {
|
|||||||
) throws {
|
) throws {
|
||||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||||
var observed = [UInt8](repeating: 0, count: 32)
|
var observed = [UInt8](repeating: 0, count: 32)
|
||||||
|
// Why a failed connect failed (PunktfunkStatus): lets a typed host rejection
|
||||||
|
// ("denied in the console", "approval timed out", "host busy") surface as
|
||||||
|
// `.rejected` instead of the undifferentiated `.connectFailed`.
|
||||||
|
var connectStatus: Int32 = 0
|
||||||
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||||
@@ -320,24 +401,29 @@ public final class PunktfunkConnection {
|
|||||||
withOptionalCString(launchID) { launch in
|
withOptionalCString(launchID) { launch in
|
||||||
if let pin = pinSHA256 {
|
if let pin = pinSHA256 {
|
||||||
return pin.withUnsafeBytes { p in
|
return pin.withUnsafeBytes { p in
|
||||||
punktfunk_connect_ex7(
|
punktfunk_connect_ex8(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
videoCodecs, preferredCodec, launch,
|
videoCodecs, preferredCodec, launch,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||||
cert, key, timeoutMs)
|
cert, key, timeoutMs, &connectStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return punktfunk_connect_ex7(
|
return punktfunk_connect_ex8(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
videoCodecs, preferredCodec, launch,
|
videoCodecs, preferredCodec, launch,
|
||||||
nil, &observed, cert, key, timeoutMs)
|
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
guard handle != nil else {
|
||||||
|
if let rejection = HostRejection(status: connectStatus) {
|
||||||
|
throw PunktfunkClientError.rejected(rejection)
|
||||||
|
}
|
||||||
|
throw PunktfunkClientError.connectFailed
|
||||||
|
}
|
||||||
hostFingerprint = Data(observed)
|
hostFingerprint = Data(observed)
|
||||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||||
@@ -372,6 +458,9 @@ public final class PunktfunkConnection {
|
|||||||
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||||
_ = punktfunk_connection_codec(handle, &codec)
|
_ = punktfunk_connection_codec(handle, &codec)
|
||||||
resolvedCodec = codec
|
resolvedCodec = codec
|
||||||
|
var shard: UInt32 = 1408
|
||||||
|
_ = punktfunk_connection_shard_payload(handle, &shard)
|
||||||
|
shardPayload = shard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||||
@@ -445,6 +534,35 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_request_keyframe(h)
|
_ = punktfunk_connection_request_keyframe(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||||
|
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||||
|
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||||
|
/// picture that never arrived — the core fires a THROTTLED RFI request for the lost range, and an
|
||||||
|
/// RFI-capable host (AMD LTR / NVENC) recovers with a clean P-frame rather than a 20-40× IDR
|
||||||
|
/// spike. Call it for every received AU; the `framesDropped`-driven `requestKeyframe()` path stays
|
||||||
|
/// the backstop for when the recovery frame itself is lost. Cheap; silently dropped after close.
|
||||||
|
public func noteFrameIndex(_ frameIndex: UInt32) {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
_ = punktfunk_connection_note_frame_index(h, frameIndex, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `noteFrameIndex`, but also reports whether the core saw a FORWARD frame-index gap — the
|
||||||
|
/// signal that intervening frames were lost and the following AUs reference a picture that never
|
||||||
|
/// arrived. The post-loss re-anchor gate arms its display freeze on a gap (the earliest, most
|
||||||
|
/// precise loss trigger — ahead of the `framesDropped` climb). Same core side effect as
|
||||||
|
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||||
|
/// after close.
|
||||||
|
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return false }
|
||||||
|
var gap = false
|
||||||
|
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||||
|
return gap
|
||||||
|
}
|
||||||
|
|
||||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||||
@@ -460,6 +578,30 @@ public final class PunktfunkConnection {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report one decoded frame's decode-stage latency, in microseconds (the AU leaving `nextAU`
|
||||||
|
/// through its VideoToolbox output). This feeds the Automatic bitrate controller's decode
|
||||||
|
/// signal — the only one that sees this device's decoder — so the rate is capped at the real
|
||||||
|
/// decode limit instead of climbing to the network link ceiling and choking the decoder. Cheap;
|
||||||
|
/// silently dropped after close. Only worth calling when `wantsDecodeLatency()` is true.
|
||||||
|
public func reportDecodeUs(_ us: UInt32) {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
_ = punktfunk_connection_report_decode_us(h, us)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `reportDecodeUs` is worth calling this session: true only when the adaptive-bitrate
|
||||||
|
/// controller is armed (Automatic bitrate, non-PyroWave). Query once — constant for the session
|
||||||
|
/// — and skip the per-frame decode measurement entirely when it's false. False after close.
|
||||||
|
public func wantsDecodeLatency() -> Bool {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return false }
|
||||||
|
var out = false
|
||||||
|
_ = punktfunk_connection_wants_decode_latency(h, &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||||
abiLock.lock()
|
abiLock.lock()
|
||||||
@@ -681,6 +823,15 @@ public final class PunktfunkConnection {
|
|||||||
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||||
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||||
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
||||||
|
/// PyroWave (opt-in wired-LAN wavelet codec, 8-bit SDR): the host only ever resolves it
|
||||||
|
/// when the client both advertises the bit AND names it `preferredCodec` — never
|
||||||
|
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
|
||||||
|
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
|
||||||
|
|
||||||
|
/// `AccessUnit.flags` bit: the AU is shard-aligned self-delimiting chunks (the wire's
|
||||||
|
/// `USER_FLAG_CHUNK_ALIGNED`, PyroWave datagram-aligned mode §4.4) — walk it
|
||||||
|
/// window-by-window at `shardPayload`. (The C `#define` doesn't import into Swift.)
|
||||||
|
public static let userFlagChunkAligned: UInt32 = 64
|
||||||
|
|
||||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever
|
// Gamepad capture → punktfunk/1 datagrams. Forwards EVERY controller GamepadManager selected —
|
||||||
// GamepadManager selected — as pad 0, for the lifetime of a streaming session.
|
// each on its own stable wire pad index (pf-client-core's slot model) — for the lifetime of a
|
||||||
|
// streaming session. One physical controller with no pin is player 0 (byte-identical to the old
|
||||||
|
// single-pad path); a pin forwards only that one, also as pad 0.
|
||||||
//
|
//
|
||||||
// The wire is incremental (one button/axis transition per 18-byte event, accumulated
|
// Each forwarded controller gets a `Slot`: its open GC handlers plus the wire state (buttons,
|
||||||
// host-side into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the
|
// axes, touchpad fingers, motion throttle) for its pad index — isolated per device so two
|
||||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
// controllers never clobber each other. On connect a slot opens (GamepadArrival declares its
|
||||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
// kind, then input flows); on disconnect / pin change / stop it closes (held state flushed to
|
||||||
|
// rest on the wire, then GamepadRemove tells the host to tear the pad's virtual device down).
|
||||||
|
//
|
||||||
|
// The wire is incremental (one button/axis transition per 18-byte event, accumulated host-side
|
||||||
|
// into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the full
|
||||||
|
// GCExtendedGamepad state on every valueChanged and diff against the previous snapshot. Sticks
|
||||||
|
// are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. The core folds these
|
||||||
|
// per-pad transitions into idempotent, sequence-numbered snapshots keyed on the same pad index,
|
||||||
|
// so all this layer must get right is the index — one controller per slot, one slot per index.
|
||||||
//
|
//
|
||||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion samples in
|
||||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
// raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — derived from the
|
||||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
// host's fixed calibration blob; the conversion lives in ONE place, `Wire`, so a live sign/scale
|
||||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
// correction is a one-line change). The host ignores both unless a pad's virtual device is a
|
||||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
// DualSense or DualShock 4 — both carry a touchpad and motion, so the capture below covers either
|
||||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
// (`GCDualShockGamepad` exposes the same `touchpad*` surface as `GCDualSenseGamepad`).
|
||||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
|
||||||
//
|
//
|
||||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture toggle — a
|
||||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
// controller can't click local UI, so it always drives the host while the app is active. On
|
||||||
// is active. On deactivation, controller switch, or stop, every held control is released
|
// deactivation, controller switch, or stop, every held control is released on the wire (the host
|
||||||
// on the wire (the host pad would otherwise stay stuck on the last state).
|
// pad would otherwise stay stuck on the last state).
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -33,17 +42,35 @@ import GameController
|
|||||||
public final class GamepadCapture {
|
public final class GamepadCapture {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
private let manager: GamepadManager
|
private let manager: GamepadManager
|
||||||
private var activeSub: AnyCancellable?
|
private var forwardedSub: AnyCancellable?
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var bound: GCController?
|
|
||||||
/// App inactive → GC stops delivering; everything is released and stays silent.
|
/// App inactive → GC stops delivering; everything is released and stays silent.
|
||||||
private var suspended = false
|
private var suspended = false
|
||||||
|
|
||||||
// Last wire state (the diff base — also what releaseAll() unwinds).
|
/// One forwarded controller: the open device plus the last wire state for its pad index (the
|
||||||
private var buttons: UInt32 = 0
|
/// diff base — also what `flush` unwinds). Held per Slot so two controllers never clobber each
|
||||||
private var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
/// other's held buttons/axes/fingers. Mirrors pf-client-core's `Slot`.
|
||||||
private var fingerActive: [Bool] = [false, false]
|
private final class Slot {
|
||||||
private var lastMotionNs: UInt64 = 0
|
let controller: GCController
|
||||||
|
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||||
|
/// event this controller sends — the low byte of `flags`.
|
||||||
|
let pad: UInt32
|
||||||
|
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
||||||
|
let pref: PunktfunkConnection.GamepadType
|
||||||
|
var buttons: UInt32 = 0
|
||||||
|
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||||
|
var fingerActive: [Bool] = [false, false]
|
||||||
|
var lastMotionNs: UInt64 = 0
|
||||||
|
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||||
|
self.controller = controller
|
||||||
|
self.pad = pad
|
||||||
|
self.pref = pref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open forwarded controllers, one Slot per physical pad on its own wire index. Reconciled
|
||||||
|
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
|
||||||
|
private var slots: [Slot] = []
|
||||||
|
|
||||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
private static let motionIntervalNs: UInt64 = 4_000_000
|
||||||
@@ -71,10 +98,14 @@ public final class GamepadCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func start() {
|
public func start() {
|
||||||
// Fires immediately with the current selection, then on every change — a switch
|
// Session-scoped index assignment: a controller pinned before the session forwards as
|
||||||
// releases the old controller's wire state before the new one takes over.
|
// pad 0 (pf-client-core assigns indices at slot-open time, not app-launch time).
|
||||||
activeSub = manager.$active.sink { [weak self] dc in
|
manager.resetForwardingAssignment()
|
||||||
MainActor.assumeIsolated { self?.rebind(to: dc?.controller) }
|
// Fires immediately with the current forwarded set, then on every change — a connect,
|
||||||
|
// disconnect, or pin change reconciles the open slots against it (opening/closing devices
|
||||||
|
// and flushing wire state so nothing sticks down).
|
||||||
|
forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||||
|
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let resign = NSApplication.willResignActiveNotification
|
let resign = NSApplication.willResignActiveNotification
|
||||||
@@ -97,53 +128,56 @@ public final class GamepadCapture {
|
|||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.suspended = false
|
self.suspended = false
|
||||||
if let ext = self.bound?.extendedGamepad { self.sync(ext) }
|
// Re-send every open pad's current state (GC delivered nothing while inactive).
|
||||||
|
for slot in self.slots {
|
||||||
|
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public func stop() {
|
public func stop() {
|
||||||
releaseAll()
|
closeAllSlots()
|
||||||
rebind(to: nil)
|
forwardedSub = nil
|
||||||
activeSub = nil
|
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
observers.removeAll()
|
observers.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rebind(to controller: GCController?) {
|
/// Bring `slots` in line with the forwarded set: close any slot no longer wanted (flushing its
|
||||||
guard controller !== bound else { return }
|
/// held wire state and sending GamepadRemove first) and open any newly-forwarded controller into
|
||||||
releaseAll()
|
/// its assigned wire index. A controller that stays forwarded keeps its slot untouched, so a
|
||||||
if let ext = bound?.extendedGamepad {
|
/// second pad connecting never disturbs the first. Mirrors pf-client-core's `reconcile_slots`.
|
||||||
ext.valueChangedHandler = nil
|
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||||
let tp = Self.touchpad(ext)
|
let wantIDs = Set(forwarded.map { ObjectIdentifier($0.controller) })
|
||||||
tp?.primary.valueChangedHandler = nil
|
for slot in slots where !wantIDs.contains(ObjectIdentifier(slot.controller)) {
|
||||||
tp?.secondary.valueChangedHandler = nil
|
closeSlot(slot)
|
||||||
}
|
}
|
||||||
// Hand the system gestures back to the OS before letting the old pad go — outside a
|
for dc in forwarded where !slots.contains(where: { $0.controller === dc.controller }) {
|
||||||
// stream the share button's screenshot and the Home overlay are the user's, not ours.
|
openSlot(dc)
|
||||||
if let old = bound {
|
|
||||||
for element in old.physicalInputProfile.elements.values {
|
|
||||||
element.preferredSystemGestureState = .enabled
|
|
||||||
}
|
}
|
||||||
|
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||||
|
updateEscapeChord()
|
||||||
}
|
}
|
||||||
if let motion = bound?.motion {
|
|
||||||
motion.valueChangedHandler = nil
|
|
||||||
// Power the sensors back down — left active they keep the pad streaming
|
|
||||||
// gyro/accel over Bluetooth (battery drain) long after the session.
|
|
||||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
|
||||||
}
|
|
||||||
bound = controller
|
|
||||||
guard let c = controller, let ext = c.extendedGamepad else { return }
|
|
||||||
|
|
||||||
ext.valueChangedHandler = { [weak self] g, _ in
|
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||||
MainActor.assumeIsolated { self?.sync(g) }
|
/// system gestures, declare its kind (GamepadArrival — before any input), then wake the host
|
||||||
|
/// pad and send its initial state. Skipped when the pad has no wire index (every slot taken)
|
||||||
|
/// or exposes no extended profile.
|
||||||
|
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||||
|
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||||
|
let c = dc.controller
|
||||||
|
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
||||||
|
slots.append(slot)
|
||||||
|
|
||||||
|
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||||
|
MainActor.assumeIsolated { if let self, let slot { self.sync(slot, g) } }
|
||||||
}
|
}
|
||||||
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
||||||
// gestures to several controller buttons — share/create → local screenshot/recording,
|
// gestures to several controller buttons — share/create → local screenshot/recording,
|
||||||
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
||||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||||
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
|
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||||
for element in c.physicalInputProfile.elements.values {
|
for element in c.physicalInputProfile.elements.values {
|
||||||
element.preferredSystemGestureState = .disabled
|
element.preferredSystemGestureState = .disabled
|
||||||
}
|
}
|
||||||
@@ -153,67 +187,114 @@ public final class GamepadCapture {
|
|||||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||||
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
||||||
home.pressedChangedHandler = { [weak self] _, _, pressed in
|
home.pressedChangedHandler = { [weak self, weak slot] _, _, pressed in
|
||||||
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
|
MainActor.assumeIsolated { if let self, let slot { self.sendGuide(slot, down: pressed) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wake the host pad immediately (pads are created lazily from the first event;
|
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||||
// a DualSense's UHID handshake + initial lightbar write only start then).
|
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
||||||
sync(ext)
|
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||||
|
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||||
|
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||||
|
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||||
|
sync(slot, ext)
|
||||||
|
|
||||||
if let tp = Self.touchpad(ext) {
|
if let tp = Self.touchpad(ext) {
|
||||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
tp.primary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 0, x: x, y: y) } }
|
||||||
}
|
}
|
||||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
tp.secondary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let motion = c.motion {
|
if let motion = c.motion {
|
||||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||||
motion.valueChangedHandler = { [weak self] m in
|
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||||
MainActor.assumeIsolated { self?.forwardMotion(m) }
|
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the profile into wire state and send every transition since the last one.
|
/// Flush a slot's held wire state (so nothing sticks down host-side) and signal the host to tear
|
||||||
private func sync(_ g: GCExtendedGamepad) {
|
/// its virtual device down (GamepadRemove), then detach GC handlers, hand the system gestures
|
||||||
|
/// back, and power the sensors down. Wire-only until the GC cleanup, so it is safe even when the
|
||||||
|
/// device already physically unplugged. Mirrors pf-client-core's `close_slot_at`.
|
||||||
|
private func closeSlot(_ slot: Slot) {
|
||||||
|
flush(slot)
|
||||||
|
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||||
|
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||||
|
connection.send(.gamepadRemove(pad: slot.pad))
|
||||||
|
let c = slot.controller
|
||||||
|
if let ext = c.extendedGamepad {
|
||||||
|
ext.valueChangedHandler = nil
|
||||||
|
let tp = Self.touchpad(ext)
|
||||||
|
tp?.primary.valueChangedHandler = nil
|
||||||
|
tp?.secondary.valueChangedHandler = nil
|
||||||
|
}
|
||||||
|
c.physicalInputProfile.buttons[GCInputButtonHome]?.pressedChangedHandler = nil
|
||||||
|
// Hand the system gestures back to the OS before letting the pad go — outside a stream the
|
||||||
|
// share button's screenshot and the Home overlay are the user's, not ours.
|
||||||
|
for element in c.physicalInputProfile.elements.values {
|
||||||
|
element.preferredSystemGestureState = .enabled
|
||||||
|
}
|
||||||
|
if let motion = c.motion {
|
||||||
|
motion.valueChangedHandler = nil
|
||||||
|
// Power the sensors back down — left active they keep the pad streaming gyro/accel
|
||||||
|
// over Bluetooth (battery drain) long after the session.
|
||||||
|
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
||||||
|
}
|
||||||
|
slots.removeAll { $0 === slot }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeAllSlots() {
|
||||||
|
while let slot = slots.first { closeSlot(slot) }
|
||||||
|
chordTimer?.invalidate()
|
||||||
|
chordTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the profile into a slot's wire state and send every transition since the last one,
|
||||||
|
/// tagged with the slot's wire pad index.
|
||||||
|
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let newButtons = Self.buttonMask(g)
|
// guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out
|
||||||
updateEscapeChord(newButtons)
|
// of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it
|
||||||
let changed = newButtons ^ buttons
|
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||||
|
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||||
|
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||||
|
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||||
|
let changed = newButtons ^ slot.buttons
|
||||||
if changed != 0 {
|
if changed != 0 {
|
||||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0))
|
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||||
}
|
}
|
||||||
buttons = newButtons
|
slot.buttons = newButtons
|
||||||
}
|
}
|
||||||
let newAxes: [Int32] = [
|
let newAxes: [Int32] = [
|
||||||
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
|
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||||
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()),
|
Int32(g.leftThumbstick.yAxis.value * 32767),
|
||||||
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()),
|
Int32(g.rightThumbstick.xAxis.value * 32767),
|
||||||
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()),
|
Int32(g.rightThumbstick.yAxis.value * 32767),
|
||||||
Int32((g.leftTrigger.value * 255).rounded()),
|
Int32(g.leftTrigger.value * 255),
|
||||||
Int32((g.rightTrigger.value * 255).rounded()),
|
Int32(g.rightTrigger.value * 255),
|
||||||
]
|
]
|
||||||
for (i, v) in newAxes.enumerated() where v != axes[i] {
|
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0))
|
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||||
axes[i] = v
|
slot.axes[i] = v
|
||||||
}
|
}
|
||||||
|
updateEscapeChord()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||||
/// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by
|
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||||
/// `releaseAll` on focus loss just like the others.
|
/// released by `flush` on focus loss / close just like the others.
|
||||||
private func sendGuide(down: Bool) {
|
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let bit = GamepadWire.guide
|
let bit = GamepadWire.guide
|
||||||
let now = down ? (buttons | bit) : (buttons & ~bit)
|
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||||
guard now != buttons else { return }
|
guard now != slot.buttons else { return }
|
||||||
connection.send(.gamepadButton(bit, down: down, pad: 0))
|
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||||
buttons = now
|
slot.buttons = now
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
|
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
|
||||||
@@ -224,17 +305,21 @@ public final class GamepadCapture {
|
|||||||
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
||||||
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
||||||
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
||||||
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button —
|
// The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone
|
||||||
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
|
// pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture
|
||||||
// the create button BOTH as buttonOptions and as the share element this OR is harmless —
|
// bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad
|
||||||
// same wire bit.
|
// this is a button physically DISTINCT from View (buttonOptions, above), so it must not
|
||||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
|
// collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam
|
||||||
|
// quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and
|
||||||
|
// this share element now emits back+misc1 for it — harmless on a plain xpad session (no
|
||||||
|
// misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad.
|
||||||
|
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 }
|
||||||
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
||||||
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
||||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||||
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
|
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
|
||||||
// guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's
|
// guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's
|
||||||
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`.
|
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `openSlot`.
|
||||||
if g.buttonA.isPressed { b |= GamepadWire.a }
|
if g.buttonA.isPressed { b |= GamepadWire.a }
|
||||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||||
@@ -262,29 +347,29 @@ public final class GamepadCapture {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
/// One touchpad finger moved on a slot's pad. GC reports ±1 positions and snaps to exactly
|
||||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
/// (0, 0) on lift — treated as the lift signal (a real finger landing on the precise center
|
||||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||||
private func touch(finger: Int, x: Float, y: Float) {
|
private func touch(_ slot: Slot, finger: Int, x: Float, y: Float) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let lifted = x == 0 && y == 0
|
let lifted = x == 0 && y == 0
|
||||||
if lifted {
|
if lifted {
|
||||||
if fingerActive[finger] {
|
if slot.fingerActive[finger] {
|
||||||
fingerActive[finger] = false
|
slot.fingerActive[finger] = false
|
||||||
connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0)
|
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fingerActive[finger] = true
|
slot.fingerActive[finger] = true
|
||||||
let w = GamepadWire.touchpad(x: x, y: y)
|
let w = GamepadWire.touchpad(x: x, y: y)
|
||||||
connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func forwardMotion(_ m: GCMotion) {
|
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let now = DispatchTime.now().uptimeNanoseconds
|
let now = DispatchTime.now().uptimeNanoseconds
|
||||||
guard now &- lastMotionNs >= Self.motionIntervalNs else { return }
|
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
|
||||||
lastMotionNs = now
|
slot.lastMotionNs = now
|
||||||
// Total acceleration in g: gravity + user when split, else the raw vector.
|
// Total acceleration in g: gravity + user when split, else the raw vector.
|
||||||
let ax: Float
|
let ax: Float
|
||||||
let ay: Float
|
let ay: Float
|
||||||
@@ -301,6 +386,7 @@ public final class GamepadCapture {
|
|||||||
let gs = GamepadWire.gyroLSBPerRadS
|
let gs = GamepadWire.gyroLSBPerRadS
|
||||||
let as_ = GamepadWire.accelLSBPerG
|
let as_ = GamepadWire.accelLSBPerG
|
||||||
connection.sendMotion(
|
connection.sendMotion(
|
||||||
|
pad: UInt8(slot.pad),
|
||||||
gyro: (
|
gyro: (
|
||||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||||
@@ -313,13 +399,12 @@ public final class GamepadCapture {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||||
/// host's virtual pad returns to rest instead of running with the last state.
|
/// moment none do — a release, or the holding pad unplugged (pf-client-core's `chord_held` is
|
||||||
/// Arm the disconnect timer when the full chord lands, disarm the moment any of the four
|
/// likewise any-slot). GC events only arrive on state CHANGES, so a held chord needs the timer:
|
||||||
/// releases. Events only arrive on state CHANGES, so a held chord needs the timer — the
|
/// the handler won't fire again until something moves.
|
||||||
/// handler won't fire again until something moves.
|
private func updateEscapeChord() {
|
||||||
private func updateEscapeChord(_ newButtons: UInt32) {
|
let held = slots.contains { $0.buttons & Self.escapeChord == Self.escapeChord }
|
||||||
let held = newButtons & Self.escapeChord == Self.escapeChord
|
|
||||||
if held, chordTimer == nil {
|
if held, chordTimer == nil {
|
||||||
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
||||||
Task { @MainActor in self?.onDisconnectRequest?() }
|
Task { @MainActor in self?.onDisconnectRequest?() }
|
||||||
@@ -332,20 +417,31 @@ public final class GamepadCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unwind everything a slot holds on the wire: button-ups, neutral axes, lifted fingers. The
|
||||||
|
/// host's virtual pad returns to rest instead of running with the last state. Wire events only
|
||||||
|
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||||
|
/// GamepadRemove (that's `closeSlot`).
|
||||||
|
private func flush(_ slot: Slot) {
|
||||||
|
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||||
|
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||||
|
}
|
||||||
|
slot.buttons = 0
|
||||||
|
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||||
|
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||||
|
slot.axes[i] = 0
|
||||||
|
}
|
||||||
|
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||||
|
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||||
|
slot.fingerActive[f] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||||
|
/// delivering; resume re-syncs), disarms the escape chord. Distinct from `closeAllSlots`, which
|
||||||
|
/// also sends GamepadRemove and detaches handlers.
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
chordTimer?.invalidate()
|
chordTimer?.invalidate()
|
||||||
chordTimer = nil
|
chordTimer = nil
|
||||||
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
for slot in slots { flush(slot) }
|
||||||
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
|
||||||
}
|
|
||||||
buttons = 0
|
|
||||||
for (i, v) in axes.enumerated() where v != 0 {
|
|
||||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0))
|
|
||||||
axes[i] = 0
|
|
||||||
}
|
|
||||||
for (f, active) in fingerActive.enumerated() where active {
|
|
||||||
connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0)
|
|
||||||
fingerActive[f] = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,82 @@
|
|||||||
// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
|
// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
|
||||||
// HID-output (0xCD) planes and replays them on the active physical controller —
|
// HID-output (0xCD) planes and replays each update on the forwarded physical controller it is
|
||||||
|
// ADDRESSED TO by wire pad index —
|
||||||
//
|
//
|
||||||
// rumble → CHHapticEngine players (per-handle localities when the pad has them,
|
// rumble → CHHapticEngine players (per-handle localities when the pad has them,
|
||||||
// one combined engine otherwise),
|
// one combined engine otherwise), a RumbleRenderer per pad,
|
||||||
// lightbar → GCDeviceLight,
|
// lightbar → GCDeviceLight,
|
||||||
// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4),
|
// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4),
|
||||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||||
//
|
//
|
||||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
// Every forwarded controller gets a per-pad feedback slot (its RumbleRenderer + last light /
|
||||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
// player-LED / trigger state) keyed on the same wire index GamepadCapture streams it on, so a
|
||||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
// rumble the host aimed at pad 1 drives pad 1's actuator and nothing else. An update for a pad
|
||||||
// just renders rumble. GameController profile mutation
|
// with no live slot (one that just closed) is dropped. HID-output traffic exists only on
|
||||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
// PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only); the drain always
|
||||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
// polls both planes with short timeouts and never spins, so an Xbox pad just renders rumble.
|
||||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
// GameController profile mutation happens on main; CHHapticEngine work on the renderer's serial
|
||||||
// is replayed onto the new one.
|
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
|
||||||
|
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
|
||||||
|
// (triggers off, player index unset) and its renderer silenced.
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreHaptics
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
public final class GamepadFeedback {
|
public final class GamepadFeedback {
|
||||||
private let connection: PunktfunkConnection
|
private let connection: PunktfunkConnection
|
||||||
|
private let manager: GamepadManager
|
||||||
private let flag = StopFlag()
|
private let flag = StopFlag()
|
||||||
private let drainDone = DispatchSemaphore(value: 0)
|
private let drainDone = DispatchSemaphore(value: 0)
|
||||||
private var drainStarted = false
|
private var drainStarted = false
|
||||||
private let rumble = RumbleRenderer(policy: .session)
|
private var forwardedSub: AnyCancellable?
|
||||||
private var activeSub: AnyCancellable?
|
|
||||||
|
|
||||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
/// One forwarded controller's non-rumble feedback state (main-actor) — the GC target plus the
|
||||||
@MainActor private var target: GCController?
|
/// last applied lightbar / player-LED / trigger, replayed if the controller on this pad swaps.
|
||||||
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
@MainActor private final class Slot {
|
||||||
@MainActor private var lastPlayerBits: UInt8?
|
var controller: GCController?
|
||||||
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
||||||
|
var lastPlayerBits: UInt8?
|
||||||
|
var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
||||||
|
init(controller: GCController?) { self.controller = controller }
|
||||||
|
}
|
||||||
|
/// HID / lightbar / player-LED slots, keyed by wire pad index. Main-actor only.
|
||||||
|
@MainActor private var slots: [UInt8: Slot] = [:]
|
||||||
|
|
||||||
|
/// Rumble renderers keyed by wire pad index, guarded by `routingLock` so the background drain
|
||||||
|
/// thread can route an incoming envelope to the right pad's renderer while the main actor
|
||||||
|
/// reconciles the set. RumbleRenderer serializes on its own queue, so calling `apply` from the
|
||||||
|
/// drain thread is safe — only the map lookup needs the lock.
|
||||||
|
private let routingLock = NSLock()
|
||||||
|
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||||
|
|
||||||
|
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
|
||||||
|
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
|
||||||
|
/// Engine — for phone-clip pads that ship without rumble motors, where the phone body is the
|
||||||
|
/// only actuator in the player's hands. Session-scoped (the setting is read once here); nil
|
||||||
|
/// when off or where the device has no haptic actuator.
|
||||||
|
private let deviceRumble: RumbleRenderer?
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
self.manager = manager
|
||||||
|
#if os(iOS)
|
||||||
|
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||||
|
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||||
|
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||||
|
} else {
|
||||||
|
deviceRumble = nil
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
deviceRumble = nil
|
||||||
|
#endif
|
||||||
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
||||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.activeSub = manager.$active.sink { [weak self] dc in
|
self.forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||||
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
|
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +102,38 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bring the per-pad feedback slots in line with the forwarded set: drop pads no longer
|
||||||
|
/// forwarded (silence + release their renderer, reset their controller), add a slot +
|
||||||
|
/// renderer for each new pad, and retarget a pad whose controller changed (a re-plug into the
|
||||||
|
/// same freed index) — replaying its cached feedback onto the new device.
|
||||||
|
@MainActor
|
||||||
|
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||||
|
var want: [UInt8: GCController] = [:]
|
||||||
|
for dc in forwarded {
|
||||||
|
if let pad = manager.padIndex(for: dc) { want[pad] = dc.controller }
|
||||||
|
}
|
||||||
|
for (pad, slot) in slots where want[pad] == nil {
|
||||||
|
reset(slot.controller)
|
||||||
|
slots[pad] = nil
|
||||||
|
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||||
|
renderer?.stop()
|
||||||
|
}
|
||||||
|
for (pad, controller) in want {
|
||||||
|
if let slot = slots[pad] {
|
||||||
|
guard slot.controller !== controller else { continue }
|
||||||
|
reset(slot.controller)
|
||||||
|
slot.controller = controller
|
||||||
|
withRouting { rumbleByPad[pad]?.retarget(controller) }
|
||||||
|
replay(slot)
|
||||||
|
} else {
|
||||||
|
slots[pad] = Slot(controller: controller)
|
||||||
|
let renderer = RumbleRenderer(policy: .session)
|
||||||
|
renderer.retarget(controller)
|
||||||
|
withRouting { rumbleByPad[pad] = renderer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func start() {
|
public func start() {
|
||||||
guard !drainStarted else { return }
|
guard !drainStarted else { return }
|
||||||
drainStarted = true
|
drainStarted = true
|
||||||
@@ -88,19 +155,19 @@ public final class GamepadFeedback {
|
|||||||
// rumble/HID latency low while leaving the lock free between polls.
|
// rumble/HID latency low while leaving the lock free between polls.
|
||||||
//
|
//
|
||||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||||
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
|
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
||||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
||||||
// and its drop-newest overflow could shed a stop while stale nonzero states
|
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
||||||
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||||
var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
|
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
||||||
var rumbleBurst = 0
|
var rumbleBurst = 0
|
||||||
while rumbleBurst < 64, !flag.isStopped,
|
while rumbleBurst < 64, !flag.isStopped,
|
||||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
let r = try connection.nextRumble2(timeoutMs: 0) {
|
||||||
if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
|
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
if let n = newest {
|
for (pad, n) in newestByPad {
|
||||||
self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
|
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
||||||
}
|
}
|
||||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
@@ -126,7 +193,7 @@ public final class GamepadFeedback {
|
|||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one
|
/// Stop the drain and silence every pad's motors. Blocks until the drain thread exits (≤ one
|
||||||
/// poll cycle) — call off the main actor, before `connection.close()`.
|
/// poll cycle) — call off the main actor, before `connection.close()`.
|
||||||
public func stop() {
|
public func stop() {
|
||||||
flag.stop()
|
flag.stop()
|
||||||
@@ -134,17 +201,37 @@ public final class GamepadFeedback {
|
|||||||
drainDone.wait()
|
drainDone.wait()
|
||||||
drainStarted = false
|
drainStarted = false
|
||||||
}
|
}
|
||||||
rumble.stop()
|
let renderers = withRouting { () -> [RumbleRenderer] in
|
||||||
// Drop the retarget subscription and the dead session's cached feedback — a
|
let r = Array(rumbleByPad.values)
|
||||||
// controller change after teardown must not replay this session's triggers/LEDs.
|
rumbleByPad.removeAll()
|
||||||
Task { @MainActor in
|
return r
|
||||||
self.activeSub = nil
|
|
||||||
self.lastLight = nil
|
|
||||||
self.lastPlayerBits = nil
|
|
||||||
self.lastTrigger = [nil, nil]
|
|
||||||
self.reset(self.target)
|
|
||||||
self.target = nil
|
|
||||||
}
|
}
|
||||||
|
for r in renderers { r.stop() }
|
||||||
|
deviceRumble?.stop()
|
||||||
|
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||||
|
// teardown must not replay this session's triggers/LEDs.
|
||||||
|
Task { @MainActor in
|
||||||
|
self.forwardedSub = nil
|
||||||
|
for slot in self.slots.values { self.reset(slot.controller) }
|
||||||
|
self.slots.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
||||||
|
/// live renderer — one that just left the forwarded set — is dropped.
|
||||||
|
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||||
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
|
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||||
|
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||||
|
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||||
|
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||||
|
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withRouting<R>(_ body: () -> R) -> R {
|
||||||
|
routingLock.lock()
|
||||||
|
defer { routingLock.unlock() }
|
||||||
|
return body()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||||
@@ -157,40 +244,37 @@ public final class GamepadFeedback {
|
|||||||
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
|
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||||
switch ev {
|
switch ev {
|
||||||
case let .led(pad, r, g, b):
|
case let .led(pad, r, g, b):
|
||||||
guard pad == 0 else { return }
|
guard let slot = slots[pad] else { return }
|
||||||
lastLight = (r, g, b)
|
slot.lastLight = (r, g, b)
|
||||||
target?.light?.color = GCColor(
|
slot.controller?.light?.color = GCColor(
|
||||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||||
case let .playerLEDs(pad, bits):
|
case let .playerLEDs(pad, bits):
|
||||||
guard pad == 0 else { return }
|
guard let slot = slots[pad] else { return }
|
||||||
lastPlayerBits = bits
|
slot.lastPlayerBits = bits
|
||||||
target?.playerIndex = Self.playerIndex(forBits: bits)
|
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||||
case let .triggerEffect(pad, which, effect):
|
case let .triggerEffect(pad, which, effect):
|
||||||
guard pad == 0, which < 2 else { return }
|
guard which < 2, let slot = slots[pad] else { return }
|
||||||
let parsed = DualSenseTriggerEffect.parse(effect)
|
let parsed = DualSenseTriggerEffect.parse(effect)
|
||||||
lastTrigger[Int(which)] = parsed
|
slot.lastTrigger[Int(which)] = parsed
|
||||||
if let trigger = adaptiveTrigger(which) {
|
if let trigger = adaptiveTrigger(slot.controller, which) {
|
||||||
parsed.apply(to: trigger)
|
parsed.apply(to: trigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replay a pad's cached feedback onto its (swapped-in) controller so a re-plug looks the same.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func retarget(_ controller: GCController?) {
|
private func replay(_ slot: Slot) {
|
||||||
guard controller !== target else { return }
|
if let (r, g, b) = slot.lastLight {
|
||||||
reset(target)
|
slot.controller?.light?.color = GCColor(
|
||||||
target = controller
|
|
||||||
rumble.retarget(controller)
|
|
||||||
// Replay the session's feedback state so a swapped-in controller looks the same.
|
|
||||||
if let (r, g, b) = lastLight {
|
|
||||||
controller?.light?.color = GCColor(
|
|
||||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||||
}
|
}
|
||||||
if let bits = lastPlayerBits {
|
if let bits = slot.lastPlayerBits {
|
||||||
controller?.playerIndex = Self.playerIndex(forBits: bits)
|
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||||
}
|
}
|
||||||
for which in 0..<2 {
|
for which in 0..<2 {
|
||||||
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
|
if let effect = slot.lastTrigger[which],
|
||||||
|
let trigger = adaptiveTrigger(slot.controller, UInt8(which)) {
|
||||||
effect.apply(to: trigger)
|
effect.apply(to: trigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,8 +291,8 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
private func adaptiveTrigger(_ controller: GCController?, _ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
||||||
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
||||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
||||||
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
||||||
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
||||||
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
// follow `forwarded` — every forwarded controller is streamed to the host, each on its own
|
||||||
|
// wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`).
|
||||||
//
|
//
|
||||||
// Selection: the user can pin a controller in Settings (persisted under
|
// Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY
|
||||||
// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently
|
// extended controller is forwarded — each assigned a stable lowest-free pad index held for
|
||||||
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
// its forwarded lifetime, so a disconnect frees only its own index and never renumbers the
|
||||||
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
// others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad
|
||||||
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
// — an explicit single-player choice. `active` stays the single "primary" pad (the pinned
|
||||||
// documents.
|
// one, else the most recently connected extended gamepad) that the Settings / launcher / menu
|
||||||
|
// UI reads. GCController has no stable hardware serial, so the pin is a fingerprint of
|
||||||
|
// vendorName|productCategory (+ a connect-order suffix for twins); identical twin controllers
|
||||||
|
// may swap a pin across reconnects, which the Settings footer documents.
|
||||||
//
|
//
|
||||||
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
||||||
// `Settings{}` scene — there is no common ancestor view to inject from.
|
// `Settings{}` scene — there is no common ancestor view to inject from.
|
||||||
@@ -38,13 +42,14 @@ public final class GamepadManager: ObservableObject {
|
|||||||
public let hasHaptics: Bool
|
public let hasHaptics: Bool
|
||||||
public let hasMotion: Bool
|
public let hasMotion: Bool
|
||||||
public let hasAdaptiveTriggers: Bool
|
public let hasAdaptiveTriggers: Bool
|
||||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||||
/// player LEDs) and the PlayStation glyph in Settings.
|
/// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
|
||||||
public var isDualSense: Bool { kind == .dualSense }
|
/// in Settings.
|
||||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge }
|
||||||
|
/// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) — gates
|
||||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||||
public var hasTouchpadAndMotion: Bool {
|
public var hasTouchpadAndMotion: Bool {
|
||||||
kind == .dualSense || kind == .dualShock4
|
kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4
|
||||||
}
|
}
|
||||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||||
public let batteryLevel: Float?
|
public let batteryLevel: Float?
|
||||||
@@ -60,9 +65,23 @@ public final class GamepadManager: ObservableObject {
|
|||||||
/// Every detected controller, in connect order (Settings lists these).
|
/// Every detected controller, in connect order (Settings lists these).
|
||||||
@Published public private(set) var controllers: [DiscoveredController] = []
|
@Published public private(set) var controllers: [DiscoveredController] = []
|
||||||
|
|
||||||
/// The one controller forwarded to the host (pad 0); nil when none qualifies.
|
/// The single "primary" controller — the pinned one, else the most recently connected
|
||||||
|
/// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the
|
||||||
|
/// connect-time `resolveType` read this; the streaming input path uses `forwarded`.
|
||||||
@Published public private(set) var active: DiscoveredController?
|
@Published public private(set) var active: DiscoveredController?
|
||||||
|
|
||||||
|
/// The controllers forwarded to the host this session, in wire-pad-index preference order
|
||||||
|
/// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards
|
||||||
|
/// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes
|
||||||
|
/// feedback back to it, each on the index from `padIndex(for:)`.
|
||||||
|
@Published public private(set) var forwarded: [DiscoveredController] = []
|
||||||
|
|
||||||
|
/// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by
|
||||||
|
/// GCController identity. Lowest-free, held while the controller stays forwarded — a
|
||||||
|
/// disconnect frees only its own index so the others never renumber (pf-client-core's
|
||||||
|
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
||||||
|
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
||||||
|
|
||||||
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
||||||
/// reselects immediately, so a Settings Picker can bind straight to this.
|
/// reselects immediately, so a Settings Picker can bind straight to this.
|
||||||
@Published public var preferredID: String {
|
@Published public var preferredID: String {
|
||||||
@@ -159,12 +178,57 @@ public final class GamepadManager: ObservableObject {
|
|||||||
let candidates = controllers.filter(\.isExtended)
|
let candidates = controllers.filter(\.isExtended)
|
||||||
// The pin wins when present; otherwise the most recently connected extended pad
|
// The pin wins when present; otherwise the most recently connected extended pad
|
||||||
// (list is in connect order). A stale pin falls back to automatic.
|
// (list is in connect order). A stale pin falls back to automatic.
|
||||||
active = candidates.last { $0.id == preferredID } ?? candidates.last
|
let pinned = candidates.last { $0.id == preferredID }
|
||||||
|
active = pinned ?? candidates.last
|
||||||
|
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
|
||||||
|
// (explicit single-player); Automatic forwards every extended controller in connect order
|
||||||
|
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
||||||
|
let next = pinned.map { [$0] } ?? candidates
|
||||||
|
// Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in
|
||||||
|
// `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the
|
||||||
|
// instant this assignment lands — a stale map here would skip a newly-forwarded pad.
|
||||||
|
assignPadIndices(for: next)
|
||||||
|
forwarded = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays
|
||||||
|
/// forwarded) — mirrors pf-client-core's slot model, where a disconnect frees only its own
|
||||||
|
/// index and the others keep theirs. A controller already holding an index keeps it across the
|
||||||
|
/// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded).
|
||||||
|
private func assignPadIndices(for next: [DiscoveredController]) {
|
||||||
|
let live = Set(next.map { ObjectIdentifier($0.controller) })
|
||||||
|
padIndexByController = padIndexByController.filter { live.contains($0.key) }
|
||||||
|
for dc in next {
|
||||||
|
let key = ObjectIdentifier(dc.controller)
|
||||||
|
guard padIndexByController[key] == nil,
|
||||||
|
let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue }
|
||||||
|
padIndexByController[key] = free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in
|
||||||
|
/// use (pf-client-core's `lowest_free_index`).
|
||||||
|
private static func lowestFreeIndex(_ taken: Set<UInt8>) -> UInt8? {
|
||||||
|
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
|
||||||
|
public func padIndex(for controller: DiscoveredController) -> UInt8? {
|
||||||
|
padIndexByController[ObjectIdentifier(controller.controller)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every pad-index assignment and recompute from the current forwarded set — called when
|
||||||
|
/// a streaming session begins so the assignment starts fresh (a controller pinned before the
|
||||||
|
/// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core
|
||||||
|
/// assigns indices at slot-open time; this reproduces that session-scoped start.
|
||||||
|
public func resetForwardingAssignment() {
|
||||||
|
padIndexByController.removeAll()
|
||||||
|
reselect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||||
let extended = c.extendedGamepad
|
let extended = c.extendedGamepad
|
||||||
let kind = padKind(extended)
|
let kind = padKind(extended, productCategory: c.productCategory)
|
||||||
return DiscoveredController(
|
return DiscoveredController(
|
||||||
id: id,
|
id: id,
|
||||||
name: c.vendorName ?? c.productCategory,
|
name: c.vendorName ?? c.productCategory,
|
||||||
@@ -174,28 +238,40 @@ public final class GamepadManager: ObservableObject {
|
|||||||
hasLight: c.light != nil,
|
hasLight: c.light != nil,
|
||||||
hasHaptics: c.haptics != nil,
|
hasHaptics: c.haptics != nil,
|
||||||
hasMotion: c.motion != nil,
|
hasMotion: c.motion != nil,
|
||||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||||
// DualShock 4 has none.
|
// Edge included); the DualShock 4 has none.
|
||||||
hasAdaptiveTriggers: kind == .dualSense,
|
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||||
isCharging: c.battery?.batteryState == .charging,
|
isCharging: c.battery?.batteryState == .charging,
|
||||||
controller: c)
|
controller: c)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
/// subclass (+ the product-category string where the subclass is shared). Detection order
|
||||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
/// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
|
||||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
/// `GCDualSenseGamepad` too — its distinct product category splits it out), then
|
||||||
|
/// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no
|
||||||
|
/// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360`
|
||||||
|
/// (it's never forwarded anyway).
|
||||||
private static func padKind(
|
private static func padKind(
|
||||||
_ extended: GCExtendedGamepad?
|
_ extended: GCExtendedGamepad?,
|
||||||
|
productCategory: String
|
||||||
) -> PunktfunkConnection.GamepadType {
|
) -> PunktfunkConnection.GamepadType {
|
||||||
guard let extended else { return .xbox360 }
|
guard let extended else { return .xbox360 }
|
||||||
|
let category = productCategory.lowercased()
|
||||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||||
// here, so no `@available` guard is needed — matching the unguarded
|
// here, so no `@available` guard is needed — matching the unguarded
|
||||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||||
if extended is GCDualSenseGamepad { return .dualSense }
|
if extended is GCDualSenseGamepad {
|
||||||
|
return category.contains("edge") ? .dualSenseEdge : .dualSense
|
||||||
|
}
|
||||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||||
if extended is GCXboxGamepad { return .xboxOne }
|
if extended is GCXboxGamepad { return .xboxOne }
|
||||||
|
// Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single
|
||||||
|
// Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback — half a pad.
|
||||||
|
if category.contains("switch pro") || category.contains("joy-con (l/r)") {
|
||||||
|
return .switchPro
|
||||||
|
}
|
||||||
return .xbox360
|
return .xbox360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ public final class GamepadMenuInput {
|
|||||||
let stick = gamepad.leftThumbstick
|
let stick = gamepad.leftThumbstick
|
||||||
let x = stick.xAxis.value
|
let x = stick.xAxis.value
|
||||||
let y = stick.yAxis.value
|
let y = stick.yAxis.value
|
||||||
if abs(x) > abs(y), abs(x) > deadzone {
|
// Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android
|
||||||
|
// nav so a perfect 45° push resolves to the same direction on every client.
|
||||||
|
if abs(x) >= abs(y), abs(x) > deadzone {
|
||||||
return x > 0 ? .right : .left
|
return x > 0 ? .right : .left
|
||||||
} else if abs(y) > deadzone {
|
} else if abs(y) > deadzone {
|
||||||
return y > 0 ? .up : .down
|
return y > 0 ? .up : .down
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
|
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
|
||||||
// and the tests — button bits, axis ids, and the touchpad/motion unit conversions.
|
// and the tests — the pad count, button bits, axis ids, and the touchpad/motion unit conversions.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
|
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
|
||||||
public enum GamepadWire {
|
public enum GamepadWire {
|
||||||
|
/// Gamepads addressable on the wire — the pad index rides the low byte of `flags` on every
|
||||||
|
/// per-pad event, 0...15 (`punktfunk_core::input::MAX_PADS`).
|
||||||
|
public static let maxPads: Int = 16
|
||||||
|
|
||||||
public static let dpadUp: UInt32 = 0x0001
|
public static let dpadUp: UInt32 = 0x0001
|
||||||
public static let dpadDown: UInt32 = 0x0002
|
public static let dpadDown: UInt32 = 0x0002
|
||||||
public static let dpadLeft: UInt32 = 0x0004
|
public static let dpadLeft: UInt32 = 0x0004
|
||||||
@@ -22,11 +26,27 @@ public enum GamepadWire {
|
|||||||
public static let y: UInt32 = 0x8000
|
public static let y: UInt32 = 0x8000
|
||||||
/// DualSense touchpad click (Moonlight's extended-button bit position).
|
/// DualSense touchpad click (Moonlight's extended-button bit position).
|
||||||
public static let touchpadClick: UInt32 = 0x10_0000
|
public static let touchpadClick: UInt32 = 0x10_0000
|
||||||
|
/// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access
|
||||||
|
/// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to
|
||||||
|
/// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button.
|
||||||
|
public static let misc1: UInt32 = 0x0020_0000
|
||||||
|
/// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in
|
||||||
|
/// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5).
|
||||||
|
/// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not
|
||||||
|
/// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence
|
||||||
|
/// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so
|
||||||
|
/// they are intentionally absent from `allButtons` until that forwarding lands.
|
||||||
|
public static let paddle1: UInt32 = 0x0001_0000
|
||||||
|
public static let paddle2: UInt32 = 0x0002_0000
|
||||||
|
public static let paddle3: UInt32 = 0x0004_0000
|
||||||
|
public static let paddle4: UInt32 = 0x0008_0000
|
||||||
|
|
||||||
|
/// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by
|
||||||
|
/// `flush` on release. Paddles are excluded until their capture lands (see above).
|
||||||
public static let allButtons: [UInt32] = [
|
public static let allButtons: [UInt32] = [
|
||||||
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
|
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
|
||||||
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
|
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
|
||||||
a, b, x, y, touchpadClick,
|
a, b, x, y, touchpadClick, misc1,
|
||||||
]
|
]
|
||||||
|
|
||||||
public static let axisLSX: UInt32 = 0
|
public static let axisLSX: UInt32 = 0
|
||||||
|
|||||||
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
static let manual = Policy(staleAfter: nil)
|
static let manual = Policy(staleAfter: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||||
|
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||||
|
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||||
|
/// Device mode ignores `retarget`'s controller and always renders one combined motor
|
||||||
|
/// (a phone body has a single actuator).
|
||||||
|
enum Actuator {
|
||||||
|
case controller
|
||||||
|
case device
|
||||||
|
}
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
private let policy: Policy
|
private let policy: Policy
|
||||||
|
private let actuator: Actuator
|
||||||
|
|
||||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||||
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||||
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(policy: Policy = .session) {
|
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
self.actuator = actuator
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||||
@@ -468,6 +480,10 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||||
private func setup() {
|
private func setup() {
|
||||||
|
if actuator == .device {
|
||||||
|
setupDevice()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let haptics = controller?.haptics else {
|
guard let haptics = controller?.haptics else {
|
||||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
||||||
// rumble through GameController (works on Android via the standard Vibrator path, but
|
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||||
@@ -517,10 +533,41 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Device-actuator mode: one combined motor on this device's own Taptic Engine. Only an
|
||||||
|
/// iPhone has one — everything else (iPad, Mac, TV) reports no haptic hardware and latches
|
||||||
|
/// off (nothing to retry; the settings toggle is hidden there anyway, this is the backstop).
|
||||||
|
private func setupDevice() {
|
||||||
|
#if os(iOS)
|
||||||
|
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
|
||||||
|
log.info("rumble: this device has no haptic actuator — device rumble unavailable")
|
||||||
|
broken = true
|
||||||
|
reportHealth("This device has no haptic actuator.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
low = startMotor(try CHHapticEngine(), sharpness: RumbleTuning.sharpnessCombined)
|
||||||
|
} catch {
|
||||||
|
log.warning("rumble: device haptic engine creation failed: \(error, privacy: .public)")
|
||||||
|
}
|
||||||
|
if low == nil {
|
||||||
|
// Same shape as the controller path: haptics exist but the engine couldn't be built
|
||||||
|
// right now — back off and retry, don't latch off.
|
||||||
|
scheduleRetryBackoff()
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
broken = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func makeMotor(
|
private func makeMotor(
|
||||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||||
) -> Motor? {
|
) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||||
|
return startMotor(engine, sharpness: sharpness)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure + start an engine (controller-locality or the device's own) into a [`Motor`].
|
||||||
|
private func startMotor(_ engine: CHHapticEngine, sharpness: Float) -> Motor? {
|
||||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||||
// letting a haptics-only engine join it is a needless coupling that can get its
|
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||||
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
try engine.start()
|
try engine.start()
|
||||||
return Motor(engine: engine, sharpness: sharpness)
|
return Motor(engine: engine, sharpness: sharpness)
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
log.warning("haptic engine setup failed: \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ public final class InputCapture {
|
|||||||
/// its Esc suppression need it in both states).
|
/// its Esc suppression need it in both states).
|
||||||
private var cmdKeysDown: Set<UInt32> = []
|
private var cmdKeysDown: Set<UInt32> = []
|
||||||
|
|
||||||
|
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
|
||||||
|
/// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
|
||||||
|
/// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states,
|
||||||
|
/// exactly like `cmdKeysDown`, and flushed by `releaseAll` when GC delivery stops.
|
||||||
|
private var chordModifiersDown: Set<UInt32> = []
|
||||||
|
|
||||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||||
/// locally; while false the user is interacting with the local UI (dragging the
|
/// locally; while false the user is interacting with the local UI (dragging the
|
||||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||||
@@ -119,6 +125,21 @@ public final class InputCapture {
|
|||||||
public var onDisconnect: (() -> Void)?
|
public var onDisconnect: (() -> Void)?
|
||||||
public var onCycleStats: (() -> Void)?
|
public var onCycleStats: (() -> Void)?
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||||
|
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||||
|
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||||
|
|
||||||
|
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||||
|
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||||
|
private var hasReleaseChordModifiers: Bool {
|
||||||
|
let m = chordModifiersDown
|
||||||
|
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||||
|
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||||
|
&& (m.contains(0xA0) || m.contains(0xA1)) // shift
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||||
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
||||||
@@ -294,6 +315,7 @@ public final class InputCapture {
|
|||||||
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
cmdKeysDown.removeAll()
|
cmdKeysDown.removeAll()
|
||||||
|
chordModifiersDown.removeAll()
|
||||||
suppressedVK = nil
|
suppressedVK = nil
|
||||||
for vk in pressedVKs {
|
for vk in pressedVKs {
|
||||||
connection.send(.key(vk, down: false))
|
connection.send(.key(vk, down: false))
|
||||||
@@ -576,6 +598,13 @@ public final class InputCapture {
|
|||||||
self.cmdKeysDown.remove(vk)
|
self.cmdKeysDown.remove(vk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||||
|
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||||
|
if Self.chordModifierVKs.contains(vk) {
|
||||||
|
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
||||||
// engage direction forwarding is already true when this fires.
|
// engage direction forwarding is already true when this fires.
|
||||||
if vk == self.suppressedVK {
|
if vk == self.suppressedVK {
|
||||||
@@ -592,6 +621,18 @@ public final class InputCapture {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
guard self.forwarding else { return }
|
guard self.forwarding else { return }
|
||||||
|
#if os(iOS)
|
||||||
|
// ⌃⌥⇧Q releases the captured mouse/keyboard (cross-client parity — the same combo the
|
||||||
|
// macOS keyDown monitor handles). Recognized only while forwarding (nothing to release
|
||||||
|
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||||
|
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||||
|
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||||
|
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||||
|
self.suppressedVK = 0x51
|
||||||
|
self.onReleaseCapture?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||||
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
||||||
|
|||||||
@@ -118,3 +118,44 @@ extension InputCapture {
|
|||||||
]
|
]
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// US-layout character → Windows VK for the on-screen keyboard (`StreamLayerUIView`'s
|
||||||
|
/// UIKeyInput). Unlike every other key source, `insertText` delivers CHARACTERS, not key
|
||||||
|
/// positions, so this is the inverse of a US layout: `shift` means "wrap in VK_LSHIFT so the
|
||||||
|
/// host types the shifted symbol". Same contract as `hidToVK`: emit only VKs the host's
|
||||||
|
/// vk_to_evdev knows; anything unmapped is dropped by the caller.
|
||||||
|
enum SoftKeyMap {
|
||||||
|
static func vk(for ch: Character) -> (vk: UInt32, shift: Bool)? {
|
||||||
|
guard let ascii = ch.asciiValue else { return nil }
|
||||||
|
switch ascii {
|
||||||
|
case UInt8(ascii: "a")...UInt8(ascii: "z"): return (UInt32(ascii) - 0x20, false)
|
||||||
|
case UInt8(ascii: "A")...UInt8(ascii: "Z"): return (UInt32(ascii), true)
|
||||||
|
case UInt8(ascii: "0")...UInt8(ascii: "9"): return (UInt32(ascii), false)
|
||||||
|
case 0x0A, 0x0D: return (0x0D, false) // return
|
||||||
|
case 0x09: return (0x09, false) // tab
|
||||||
|
case 0x20: return (0x20, false) // space
|
||||||
|
default: return symbols[ch]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// US punctuation, plain and shifted, on the OEM VKs (mirrors `hidToVK`'s OEM block) plus
|
||||||
|
/// the shifted digit row.
|
||||||
|
private static let symbols: [Character: (vk: UInt32, shift: Bool)] = [
|
||||||
|
"-": (0xBD, false), "_": (0xBD, true),
|
||||||
|
"=": (0xBB, false), "+": (0xBB, true),
|
||||||
|
"[": (0xDB, false), "{": (0xDB, true),
|
||||||
|
"]": (0xDD, false), "}": (0xDD, true),
|
||||||
|
"\\": (0xDC, false), "|": (0xDC, true),
|
||||||
|
";": (0xBA, false), ":": (0xBA, true),
|
||||||
|
"'": (0xDE, false), "\"": (0xDE, true),
|
||||||
|
"`": (0xC0, false), "~": (0xC0, true),
|
||||||
|
",": (0xBC, false), "<": (0xBC, true),
|
||||||
|
".": (0xBE, false), ">": (0xBE, true),
|
||||||
|
"/": (0xBF, false), "?": (0xBF, true),
|
||||||
|
"!": (0x31, true), "@": (0x32, true), "#": (0x33, true), "$": (0x34, true),
|
||||||
|
"%": (0x35, true), "^": (0x36, true), "&": (0x37, true), "*": (0x38, true),
|
||||||
|
"(": (0x39, true), ")": (0x30, true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||||
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||||
// (off → compact → normal → detailed, matching Android):
|
// (off → compact → normal → detailed, matching Android) · three-finger swipe up/down =
|
||||||
|
// summon/dismiss the local soft keyboard for typing on the host (`onKeyboardGesture`):
|
||||||
//
|
//
|
||||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||||
@@ -61,6 +62,9 @@ final class TouchMouse {
|
|||||||
static let accelGain: CGFloat = 0.6
|
static let accelGain: CGFloat = 0.6
|
||||||
static let accelSpeedFloor: CGFloat = 0.3
|
static let accelSpeedFloor: CGFloat = 0.3
|
||||||
static let accelMax: CGFloat = 3.0
|
static let accelMax: CGFloat = 3.0
|
||||||
|
/// Three-finger vertical swipe: the fraction of the view height the centroid must
|
||||||
|
/// travel to summon (up) / dismiss (down) the local soft keyboard.
|
||||||
|
static let keyboardSwipeFraction: CGFloat = 0.10
|
||||||
|
|
||||||
/// Acceleration multiplier for a finger speed in physical px per ms.
|
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||||
@@ -72,6 +76,9 @@ final class TouchMouse {
|
|||||||
var send: ((PunktfunkInputEvent) -> Void)?
|
var send: ((PunktfunkInputEvent) -> Void)?
|
||||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
||||||
|
/// Three-finger vertical swipe crossed the threshold: `true` = show the local soft
|
||||||
|
/// keyboard (swipe up), `false` = dismiss it (swipe down). Fires at most once per gesture.
|
||||||
|
var onKeyboardGesture: ((Bool) -> Void)?
|
||||||
|
|
||||||
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||||
@@ -95,6 +102,11 @@ final class TouchMouse {
|
|||||||
private var carryY: CGFloat = 0
|
private var carryY: CGFloat = 0
|
||||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||||
private var scrollAnchor = CGPoint.zero
|
private var scrollAnchor = CGPoint.zero
|
||||||
|
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the scroll
|
||||||
|
// anchor) and a once-per-gesture latch.
|
||||||
|
private var kbCount = 0
|
||||||
|
private var kbAnchor = CGPoint.zero
|
||||||
|
private var kbFired = false
|
||||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||||
private var lastTapUp: TimeInterval = 0
|
private var lastTapUp: TimeInterval = 0
|
||||||
private var lastTapPoint = CGPoint.zero
|
private var lastTapPoint = CGPoint.zero
|
||||||
@@ -114,6 +126,8 @@ final class TouchMouse {
|
|||||||
maxFingers = 0
|
maxFingers = 0
|
||||||
moved = false
|
moved = false
|
||||||
scrolling = false
|
scrolling = false
|
||||||
|
kbCount = 0
|
||||||
|
kbFired = false
|
||||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
// button for this whole gesture (laptop-trackpad convention).
|
// button for this whole gesture (laptop-trackpad convention).
|
||||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||||
@@ -140,8 +154,13 @@ final class TouchMouse {
|
|||||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||||
}
|
}
|
||||||
if lastPos.count >= 2 {
|
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3 bounce
|
||||||
|
// re-anchors instead of reading the count change as swipe travel.
|
||||||
|
if lastPos.count < 3 { kbCount = 0 }
|
||||||
|
if lastPos.count == 2 {
|
||||||
scrollByCentroid()
|
scrollByCentroid()
|
||||||
|
} else if lastPos.count >= 3 {
|
||||||
|
keyboardSwipe(in: view)
|
||||||
} else if !scrolling, let touch = touches.first(where: {
|
} else if !scrolling, let touch = touches.first(where: {
|
||||||
lastPos[ObjectIdentifier($0)] != nil
|
lastPos[ObjectIdentifier($0)] != nil
|
||||||
}) {
|
}) {
|
||||||
@@ -208,9 +227,9 @@ final class TouchMouse {
|
|||||||
|
|
||||||
// MARK: - Per-event work
|
// MARK: - Per-event work
|
||||||
|
|
||||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
||||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
||||||
/// right scrolls right (the host WHEEL(120) convention).
|
/// scrolls right (the host WHEEL(120) convention).
|
||||||
private func scrollByCentroid() {
|
private func scrollByCentroid() {
|
||||||
let n = CGFloat(lastPos.count)
|
let n = CGFloat(lastPos.count)
|
||||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||||
@@ -233,6 +252,38 @@ final class TouchMouse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three+ fingers → the keyboard swipe, never scroll (the documented vocabulary is
|
||||||
|
/// TWO-finger scroll; 3+ only fell into the scroll path as an accident of its old `>= 2`
|
||||||
|
/// bound). The centroid is anchored per finger count — real fingers never land or lift in
|
||||||
|
/// the same event, so a count change must re-anchor rather than read as travel — and the
|
||||||
|
/// gesture fires at most once, when the vertical travel crosses the threshold: up = show
|
||||||
|
/// the local soft keyboard, down = dismiss it.
|
||||||
|
private func keyboardSwipe(in view: UIView) {
|
||||||
|
let n = CGFloat(lastPos.count)
|
||||||
|
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||||
|
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
|
||||||
|
if lastPos.count != kbCount {
|
||||||
|
kbCount = lastPos.count
|
||||||
|
kbAnchor = CGPoint(x: cx, y: cy)
|
||||||
|
} else {
|
||||||
|
let dy = cy - kbAnchor.y
|
||||||
|
// Real centroid travel disqualifies the tap classification in `ended` (else a
|
||||||
|
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||||
|
if abs(dy) > Tuning.tapSlop || abs(cx - kbAnchor.x) > Tuning.tapSlop { moved = true }
|
||||||
|
if !kbFired, abs(dy) >= view.bounds.height * Tuning.keyboardSwipeFraction {
|
||||||
|
kbFired = true
|
||||||
|
onKeyboardGesture?(dy < 0) // finger up → show, finger down → dismiss
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel notch;
|
||||||
|
// clearing it makes a return to two fingers re-anchor fresh. Same for the trackpad's
|
||||||
|
// tracked finger: its prev position froze while 3+ fingers were down, so dropping
|
||||||
|
// straight back to one finger must re-anchor (zero delta), not replay the whole
|
||||||
|
// 3-finger phase as one cursor jump.
|
||||||
|
scrolling = false
|
||||||
|
trackKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||||
/// one must not jerk the cursor).
|
/// one must not jerk the cursor).
|
||||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ public enum DefaultsKey {
|
|||||||
public static let streamWidth = "punktfunk.width"
|
public static let streamWidth = "punktfunk.width"
|
||||||
public static let streamHeight = "punktfunk.height"
|
public static let streamHeight = "punktfunk.height"
|
||||||
public static let streamHz = "punktfunk.hz"
|
public static let streamHz = "punktfunk.hz"
|
||||||
|
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2): when on, the
|
||||||
|
/// stream mode FOLLOWS the session view — the connect asks for the view's pixel size and a
|
||||||
|
/// mid-session resize (a windowed macOS window, an iPad Stage Manager / Split View scene)
|
||||||
|
/// renegotiates the host's virtual display + encoder (`PunktfunkConnection.requestMode`), so a
|
||||||
|
/// windowed session streams native-resolution pixels instead of scaling. Off (default): the
|
||||||
|
/// explicit `streamWidth`/`streamHeight` are used and never auto-resized (a fullscreen session
|
||||||
|
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
||||||
|
/// stream views' `MatchWindowFollower`.
|
||||||
|
public static let matchWindow = "punktfunk.matchWindow"
|
||||||
public static let compositor = "punktfunk.compositor"
|
public static let compositor = "punktfunk.compositor"
|
||||||
public static let gamepadType = "punktfunk.gamepadType"
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
public static let gamepadID = "punktfunk.gamepadID"
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
@@ -18,8 +27,10 @@ public enum DefaultsKey {
|
|||||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
/// 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 in-core decode + AVAudioEngine layout.
|
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||||
public static let audioChannels = "punktfunk.audioChannels"
|
public static let audioChannels = "punktfunk.audioChannels"
|
||||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference —
|
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, `"av1"`, or
|
||||||
/// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`.
|
/// `"pyrowave"` (the opt-in wired-LAN wavelet codec — picking it advertises AND prefers it,
|
||||||
|
/// and forces the session SDR). A soft preference — the host emits it when it can, else
|
||||||
|
/// falls back. Drives the decoder via `Welcome.codec`.
|
||||||
public static let codec = "punktfunk.codec"
|
public static let codec = "punktfunk.codec"
|
||||||
public static let micEnabled = "punktfunk.micEnabled"
|
public static let micEnabled = "punktfunk.micEnabled"
|
||||||
public static let speakerUID = "punktfunk.speakerUID"
|
public static let speakerUID = "punktfunk.speakerUID"
|
||||||
@@ -88,6 +99,18 @@ public enum DefaultsKey {
|
|||||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||||
|
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||||
|
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||||
|
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||||
|
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||||
|
/// has a haptic actuator (no iPad/Mac/TV).
|
||||||
|
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||||
|
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||||
|
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||||
|
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||||
|
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||||
|
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||||
|
public static let autoWake = "punktfunk.autoWake"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
|
|||||||
@@ -543,19 +543,24 @@ public enum AV1 {
|
|||||||
|
|
||||||
extension VideoCodec {
|
extension VideoCodec {
|
||||||
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
|
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
|
||||||
/// header, the NAL codecs on in-band parameter sets — one call site in each pump.
|
/// header, the NAL codecs on in-band parameter sets — one call site in each pump. PyroWave
|
||||||
|
/// has no CoreMedia representation at all (its pump feeds the Metal wavelet decoder raw).
|
||||||
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
||||||
self == .av1
|
switch self {
|
||||||
? AV1.formatDescription(fromKeyframe: au)
|
case .av1: return AV1.formatDescription(fromKeyframe: au)
|
||||||
: AnnexB.formatDescription(fromIDR: au, codec: self)
|
case .pyrowave: return nil
|
||||||
|
default: return AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
||||||
public func sampleBuffer(
|
public func sampleBuffer(
|
||||||
au: AccessUnit, format: CMVideoFormatDescription
|
au: AccessUnit, format: CMVideoFormatDescription
|
||||||
) -> CMSampleBuffer? {
|
) -> CMSampleBuffer? {
|
||||||
self == .av1
|
switch self {
|
||||||
? AV1.sampleBuffer(au: au, format: format)
|
case .av1: return AV1.sampleBuffer(au: au, format: format)
|
||||||
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
case .pyrowave: return nil
|
||||||
|
default: return AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ public enum VideoCodec: Equatable {
|
|||||||
case h264
|
case h264
|
||||||
case hevc
|
case hevc
|
||||||
case av1
|
case av1
|
||||||
|
/// PyroWave wavelet (opt-in wired-LAN low-latency codec): not a NAL/OBU codec and not
|
||||||
|
/// VideoToolbox-decoded at all — the Metal wavelet decoder consumes the raw AUs
|
||||||
|
/// (Stage2Pipeline's PyroWave pump). Only ever resolved when this client both advertised
|
||||||
|
/// and preferred it.
|
||||||
|
case pyrowave
|
||||||
|
|
||||||
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
||||||
public init(wire: UInt8) {
|
public init(wire: UInt8) {
|
||||||
switch wire {
|
switch wire {
|
||||||
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
||||||
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
|
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
|
||||||
|
case 0x08: self = .pyrowave // PUNKTFUNK_CODEC_PYROWAVE
|
||||||
default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec
|
default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,8 +153,8 @@ public enum AnnexB {
|
|||||||
sets = [vps, sps, pps]
|
sets = [vps, sps, pps]
|
||||||
case .h264:
|
case .h264:
|
||||||
sets = [sps, pps]
|
sets = [sps, pps]
|
||||||
case .av1:
|
case .av1, .pyrowave:
|
||||||
return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here
|
return nil // no parameter-set NALs — dispatched in AV1.swift, never reaches here
|
||||||
}
|
}
|
||||||
|
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
@@ -184,8 +190,8 @@ public enum AnnexB {
|
|||||||
parameterSetSizes: sizes,
|
parameterSetSizes: sizes,
|
||||||
nalUnitHeaderLength: 4,
|
nalUnitHeaderLength: 4,
|
||||||
formatDescriptionOut: &format)
|
formatDescriptionOut: &format)
|
||||||
case .av1:
|
case .av1, .pyrowave:
|
||||||
break // unreachable — the .av1 arm above already returned
|
break // unreachable — the arm above already returned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status == noErr ? format : nil
|
return status == noErr ? format : nil
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Match-window resize follower (design/midstream-resolution-resize.md D1/D2, client C3).
|
||||||
|
//
|
||||||
|
// The presenting view feeds this its PHYSICAL-PIXEL size on every layout; it debounces to
|
||||||
|
// resize-end, spaces requests ≥ 1 s apart, and asks the connection to switch the host's virtual
|
||||||
|
// display + encoder to match (`PunktfunkConnection.requestMode`) — so a windowed macOS session or
|
||||||
|
// an iPad Stage Manager / Split View scene streams native-resolution pixels instead of scaling.
|
||||||
|
// The decode/present side needs nothing: VideoToolbox recreates its session on the keyframe-derived
|
||||||
|
// format-description change (the first new-mode AU is an IDR with fresh parameter sets).
|
||||||
|
//
|
||||||
|
// The trigger discipline is the shared cross-client one (mirrors the session binary's
|
||||||
|
// `resize_decision`): physical pixels rounded DOWN to even (the host rejects odd dimensions) and
|
||||||
|
// clamped ≥ 320×200; debounce to resize-end; ≥ 1 s between requests; skip a size equal to the live
|
||||||
|
// mode; and request each distinct size at most once — which both stops re-asking a rejected size
|
||||||
|
// and keeps a host-side rollback (accepted, rebuild failed, corrective ack restored the old mode)
|
||||||
|
// from looping request → rollback → request.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||||
|
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||||
|
public enum MatchWindow {
|
||||||
|
/// Even-floor + clamp a physical-pixel size to a host-valid mode dimension: the host's
|
||||||
|
/// `validate_dimensions` rejects odd sizes, and we never ask below 320×200.
|
||||||
|
public static func normalize(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||||
|
let evenClamp: (Int, UInt32) -> UInt32 = { px, minimum in
|
||||||
|
let even = UInt32(max(px, 0)) / 2 * 2
|
||||||
|
return max(even, minimum)
|
||||||
|
}
|
||||||
|
return (evenClamp(widthPx, 320), evenClamp(heightPx, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to request `target` now (the debounce has already settled; spacing is the caller's
|
||||||
|
/// timer): `nil` to skip — equal to the live mode, or already requested once (a rejected size /
|
||||||
|
/// a host rollback must not loop). `target` is expected already-[normalize]d.
|
||||||
|
public static func request(
|
||||||
|
target: (width: UInt32, height: UInt32),
|
||||||
|
current: (width: UInt32, height: UInt32),
|
||||||
|
lastRequested: (width: UInt32, height: UInt32)?
|
||||||
|
) -> (width: UInt32, height: UInt32)? {
|
||||||
|
if target.width == current.width, target.height == current.height { return nil }
|
||||||
|
if let lr = lastRequested, lr.width == target.width, lr.height == target.height { return nil }
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the debounce timer + serialization state and drives `PunktfunkConnection.requestMode` from
|
||||||
|
/// the stream view's layout callbacks. Main-actor: the views feed it on the main thread and it reads
|
||||||
|
/// the connection's live mode there. Enabled per session from the `matchWindow` setting.
|
||||||
|
@MainActor
|
||||||
|
public final class MatchWindowFollower {
|
||||||
|
private weak var connection: PunktfunkConnection?
|
||||||
|
private let debounce: TimeInterval
|
||||||
|
private let minSpacing: TimeInterval
|
||||||
|
private var enabled: Bool
|
||||||
|
|
||||||
|
private var work: DispatchWorkItem?
|
||||||
|
private var pendingSize: (width: Int, height: Int)?
|
||||||
|
private var lastRequested: (width: UInt32, height: UInt32)?
|
||||||
|
private var lastRequestAt: Date?
|
||||||
|
/// The last size we reported via [`onResizeTarget`] — dedups the per-layout stream of a drag so
|
||||||
|
/// the UI is notified once per distinct target, and reset to `nil` when the window is back in
|
||||||
|
/// sync with the live mode (so a later resize re-reports).
|
||||||
|
private var lastSteered: (width: UInt32, height: UInt32)?
|
||||||
|
|
||||||
|
/// Fired (on the main actor) the instant the window starts differing from the live mode — i.e.
|
||||||
|
/// a resize is under way and a `Reconfigure` for `(width, height)` is imminent. Drives the
|
||||||
|
/// resize overlay's INSTANT feedback (blur + spinner) BEFORE the debounced request leaves; the
|
||||||
|
/// overlay clears when a decoded frame reaches this size (or on a timeout). Deduped per target.
|
||||||
|
public var onResizeTarget: ((_ width: UInt32, _ height: UInt32) -> Void)?
|
||||||
|
|
||||||
|
/// `debounce` = quiet time after the last size event before requesting (Win32 gets
|
||||||
|
/// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests
|
||||||
|
/// (a full host pipeline rebuild each). Defaults match the other clients.
|
||||||
|
public init(
|
||||||
|
connection: PunktfunkConnection,
|
||||||
|
enabled: Bool,
|
||||||
|
debounce: TimeInterval = 0.4,
|
||||||
|
minSpacing: TimeInterval = 1.0
|
||||||
|
) {
|
||||||
|
self.connection = connection
|
||||||
|
self.enabled = enabled
|
||||||
|
self.debounce = debounce
|
||||||
|
self.minSpacing = minSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||||
|
public func setEnabled(_ on: Bool) {
|
||||||
|
enabled = on
|
||||||
|
if !on {
|
||||||
|
work?.cancel()
|
||||||
|
work = nil
|
||||||
|
pendingSize = nil
|
||||||
|
lastSteered = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed the presenting view's current PHYSICAL-PIXEL size (its `bounds` × the backing/display
|
||||||
|
/// scale). Called from every layout pass; coalesced by the debounce so a drag-resize sends one
|
||||||
|
/// request at its end, never one per frame.
|
||||||
|
public func noteSize(widthPx: Int, heightPx: Int) {
|
||||||
|
guard enabled else { return }
|
||||||
|
pendingSize = (widthPx, heightPx)
|
||||||
|
schedule()
|
||||||
|
reportSteering(widthPx: widthPx, heightPx: heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the resize overlay's START signal (deduped): the moment the normalized window size
|
||||||
|
/// differs from the live mode we're steering toward a new size. No connection / no negotiated
|
||||||
|
/// mode yet → nothing to compare against, skip.
|
||||||
|
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||||
|
guard let connection else { return }
|
||||||
|
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
||||||
|
let mode = connection.currentMode()
|
||||||
|
guard mode.width > 0, mode.height > 0 else { return }
|
||||||
|
if target.width == mode.width, target.height == mode.height {
|
||||||
|
lastSteered = nil // back in sync — a later change re-reports
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lastSteered?.width == target.width, lastSteered?.height == target.height { return }
|
||||||
|
lastSteered = target
|
||||||
|
onResizeTarget?(target.width, target.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedule() {
|
||||||
|
work?.cancel()
|
||||||
|
let item = DispatchWorkItem { [weak self] in self?.fire() }
|
||||||
|
work = item
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fire() {
|
||||||
|
guard enabled, let connection, let size = pendingSize else { return }
|
||||||
|
// ≥ 1 s spacing: a request went out recently → re-arm the debounce and retry later rather
|
||||||
|
// than fire early (keeps at most ~one request outstanding — the accept ack round-trips in
|
||||||
|
// milliseconds, ahead of the host's rebuild).
|
||||||
|
if let last = lastRequestAt, Date().timeIntervalSince(last) < minSpacing {
|
||||||
|
schedule()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
||||||
|
let mode = connection.currentMode()
|
||||||
|
pendingSize = nil
|
||||||
|
guard let req = MatchWindow.request(
|
||||||
|
target: target,
|
||||||
|
current: (mode.width, mode.height),
|
||||||
|
lastRequested: lastRequested
|
||||||
|
) else { return }
|
||||||
|
// Keep the current refresh — Match-window follows SIZE, not rate.
|
||||||
|
connection.requestMode(width: req.width, height: req.height, refreshHz: mode.refreshHz)
|
||||||
|
lastRequested = req
|
||||||
|
lastRequestAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,16 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
|
|||||||
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
||||||
constant CscUniform& csc) {
|
constant CscUniform& csc) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
|
#ifdef PF_BILINEAR_LUMA
|
||||||
|
// DEBUG (PUNKTFUNK_BILINEAR_LUMA=1): plain bilinear luma — Catmull-Rom OFF. A/B lever to see if
|
||||||
|
// the bicubic overshoot contributes to edge fringing. NOTE: at a true 1:1 present both paths
|
||||||
|
// reduce to the identity texel, so if this toggle VISIBLY changes the picture, the present is
|
||||||
|
// NOT 1:1 (there's a resample); if it looks identical, the fringing is upstream (codec/source/OS).
|
||||||
|
float lumaY = lumaTex.sample(s, uv).r;
|
||||||
|
#else
|
||||||
|
float lumaY = catmullRomLuma(lumaTex, s, uv);
|
||||||
|
#endif
|
||||||
|
float3 yuv = float3(lumaY,
|
||||||
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
||||||
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||||
@@ -140,6 +149,28 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
|||||||
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PyroWave planar SDR: three separate R8 planes (Y full-res, Cb/Cr half-res 4:2:0) from the
|
||||||
|
// Metal wavelet decoder — the Metal twin of pf-presenter's planar_csc.frag. Same bicubic luma
|
||||||
|
// and left-cosited chroma correction as the biplanar path (chromaUV self-disables at 4:4:4).
|
||||||
|
fragment float4 pf_frag_planar(VOut in [[stage_in]],
|
||||||
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
|
texture2d<float> cbTex [[texture(1)]],
|
||||||
|
texture2d<float> crTex [[texture(2)]],
|
||||||
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
|
#ifdef PF_BILINEAR_LUMA
|
||||||
|
float lumaY = lumaTex.sample(s, in.uv).r;
|
||||||
|
#else
|
||||||
|
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
|
||||||
|
#endif
|
||||||
|
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
|
||||||
|
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
|
||||||
|
float3 rgb = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||||
|
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||||
|
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||||
|
return float4(rgb, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is —
|
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is —
|
||||||
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
|
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
|
||||||
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
||||||
@@ -206,8 +237,16 @@ public final class MetalVideoPresenter {
|
|||||||
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
||||||
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
||||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
|
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||||
|
private let pipelinePlanar: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
|
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
||||||
|
/// decode, CSC and present share textures with zero interop, and one queue means Metal's
|
||||||
|
/// hazard tracking orders a ring-slot rewrite after the render still sampling it.
|
||||||
|
var metalDevice: MTLDevice { device }
|
||||||
|
var metalQueue: MTLCommandQueue { queue }
|
||||||
|
|
||||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||||
@@ -249,8 +288,18 @@ public final class MetalVideoPresenter {
|
|||||||
let pipelineSDR: MTLRenderPipelineState
|
let pipelineSDR: MTLRenderPipelineState
|
||||||
let pipelineHDR: MTLRenderPipelineState
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
|
let pipelinePlanar: MTLRenderPipelineState
|
||||||
do {
|
do {
|
||||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||||
|
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||||
|
// the normal bicubic path. Read at presenter creation — set it in the environment and
|
||||||
|
// relaunch to flip; the log line confirms which path built.
|
||||||
|
let bilinearLuma = ProcessInfo.processInfo.environment["PUNKTFUNK_BILINEAR_LUMA"] == "1"
|
||||||
|
let source = (bilinearLuma ? "#define PF_BILINEAR_LUMA 1\n" : "") + shaderSource
|
||||||
|
if bilinearLuma {
|
||||||
|
presenterLog.info("stage2: PUNKTFUNK_BILINEAR_LUMA=1 — Catmull-Rom luma DISABLED (bilinear)")
|
||||||
|
}
|
||||||
|
let library = try device.makeLibrary(source: source, options: nil)
|
||||||
let vtx = library.makeFunction(name: "pf_vtx")
|
let vtx = library.makeFunction(name: "pf_vtx")
|
||||||
let sdr = MTLRenderPipelineDescriptor()
|
let sdr = MTLRenderPipelineDescriptor()
|
||||||
sdr.vertexFunction = vtx
|
sdr.vertexFunction = vtx
|
||||||
@@ -274,6 +323,11 @@ public final class MetalVideoPresenter {
|
|||||||
#else
|
#else
|
||||||
pipelineHDRToneMap = nil
|
pipelineHDRToneMap = nil
|
||||||
#endif
|
#endif
|
||||||
|
let planar = MTLRenderPipelineDescriptor()
|
||||||
|
planar.vertexFunction = vtx
|
||||||
|
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||||
|
planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
|
||||||
|
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -313,12 +367,14 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
return MetalVideoPresenter(
|
return MetalVideoPresenter(
|
||||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||||
|
textureCache: textureCache, layer: layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(
|
private init(
|
||||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||||
|
pipelinePlanar: MTLRenderPipelineState,
|
||||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
) {
|
) {
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -326,6 +382,7 @@ public final class MetalVideoPresenter {
|
|||||||
self.pipelineSDR = pipelineSDR
|
self.pipelineSDR = pipelineSDR
|
||||||
self.pipelineHDR = pipelineHDR
|
self.pipelineHDR = pipelineHDR
|
||||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||||
|
self.pipelinePlanar = pipelinePlanar
|
||||||
self.textureCache = textureCache
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
@@ -496,6 +553,67 @@ public final class MetalVideoPresenter {
|
|||||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||||
|
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||||
|
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||||
|
let pipeline = hdrActive ? hdrPipeline : pipelineSDR
|
||||||
|
#else
|
||||||
|
let pipeline = hdrActive ? pipelineHDR : pipelineSDR
|
||||||
|
#endif
|
||||||
|
let decodedSize = CGSize(
|
||||||
|
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||||
|
return encodePresent(
|
||||||
|
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||||
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||||
|
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
|
keepAlive: [luma, chroma, pixelBuffer]
|
||||||
|
) { encoder in
|
||||||
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||||
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||||
|
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw one PyroWave planar frame (three R8 planes off the Metal wavelet decoder) and
|
||||||
|
/// present it. RENDER THREAD, same contract as `render` — PyroWave is 8-bit SDR, so the
|
||||||
|
/// layer always takes the plain SDR config, and the CSC rows arrive precomputed from the
|
||||||
|
/// stream's own sequence-header signaling (no CVPixelBuffer to inspect).
|
||||||
|
@discardableResult
|
||||||
|
func renderPlanar(
|
||||||
|
_ planes: WaveletPlanes,
|
||||||
|
presentAtMediaTime: CFTimeInterval? = nil,
|
||||||
|
onPresented: ((Int64?) -> Void)? = nil
|
||||||
|
) -> Bool {
|
||||||
|
stagingLock.lock()
|
||||||
|
let targetFromLayout = drawableTarget
|
||||||
|
stagingLock.unlock()
|
||||||
|
configure(hdr: false)
|
||||||
|
var csc = planes.csc
|
||||||
|
return encodePresent(
|
||||||
|
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||||
|
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||||
|
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||||
|
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||||
|
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||||
|
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||||
|
) { encoder in
|
||||||
|
encoder.setFragmentTexture(planes.y, index: 0)
|
||||||
|
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||||
|
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||||
|
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||||
|
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||||
|
/// the present and the on-glass callback.
|
||||||
|
private func encodePresent(
|
||||||
|
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||||
|
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||||
|
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||||
|
) -> Bool {
|
||||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||||
// SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the
|
// SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the
|
||||||
// main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
// main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
||||||
@@ -504,8 +622,6 @@ public final class MetalVideoPresenter {
|
|||||||
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
|
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
|
||||||
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
|
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
|
||||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||||
let decodedSize = CGSize(
|
|
||||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
|
||||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||||
? targetFromLayout : decodedSize
|
? targetFromLayout : decodedSize
|
||||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||||
@@ -524,17 +640,8 @@ public final class MetalVideoPresenter {
|
|||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
encoder.setRenderPipelineState(pipeline)
|
||||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
bind(encoder)
|
||||||
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
|
||||||
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
|
||||||
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
|
|
||||||
#else
|
|
||||||
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
|
||||||
#endif
|
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
|
||||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
|
||||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
encoder.endEncoding()
|
encoder.endEncoding()
|
||||||
if let onPresented {
|
if let onPresented {
|
||||||
@@ -562,9 +669,8 @@ public final class MetalVideoPresenter {
|
|||||||
} else {
|
} else {
|
||||||
commandBuffer.present(drawable)
|
commandBuffer.present(drawable)
|
||||||
}
|
}
|
||||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||||
// sampling — releasing them at scope exit could free the backing mid-read.
|
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -590,8 +696,17 @@ public final class MetalVideoPresenter {
|
|||||||
let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
|
let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
|
||||||
if sig != lastSizeSig {
|
if sig != lastSizeSig {
|
||||||
lastSizeSig = sig
|
lastSizeSig = sig
|
||||||
|
// Explicit verdict: is the shader presenting 1:1 (decoded == drawable) or resampling? The
|
||||||
|
// scale ratio makes a residual match-window mismatch obvious. If this says 1:1 but the
|
||||||
|
// picture is still soft, the resample is downstream of us (macOS compositor — a scaled
|
||||||
|
// display mode, or a fractional-pixel window position), not the shader.
|
||||||
|
let sx = decoded.width > 0 ? drawable.width / decoded.width : 0
|
||||||
|
let sy = decoded.height > 0 ? drawable.height / decoded.height : 0
|
||||||
|
let verdict = decoded == drawable
|
||||||
|
? "1:1 (no resample)"
|
||||||
|
: String(format: "RESAMPLE scale=%.4fx%.4f", sx, sy)
|
||||||
let msg =
|
let msg =
|
||||||
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)"
|
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) [\(verdict)] hdr=\(hdrActive)"
|
||||||
presenterLog.info("\(msg, privacy: .public)")
|
presenterLog.info("\(msg, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
// PyroWave native Metal decoder — the Apple twin of pf-client-core's Vulkan decoder
|
||||||
|
// (crates/pf-client-core/src/video_pyrowave.rs), reimplemented on the presenter's own MTLDevice
|
||||||
|
// so decode + CSC + present share one device with zero interop (design/pyrowave-codec-plan.md
|
||||||
|
// §4.7). No upstream C/C++ ships in the app: the bitstream parse below reimplements
|
||||||
|
// pyrowave_decoder.cpp's push_packet/decode_packet walk, and the two compute kernels
|
||||||
|
// (MetalWaveletShaders.swift) are hand-ported from the vendored GLSL. The §4.2 upstream pin
|
||||||
|
// covers this hand-port: a vendored bump means re-diffing two decode shaders and the two 8-byte
|
||||||
|
// header structs, and it is already a protocol-version event.
|
||||||
|
//
|
||||||
|
// Wire shape (all fixed by the host encoder, punktfunk-host encode/linux/pyrowave.rs):
|
||||||
|
// • One AU = one frame = a self-delimiting stream of packets. Each packet is one 32x32
|
||||||
|
// coefficient block for one (component, level, band), self-sized by its 8-byte
|
||||||
|
// BitstreamHeader; a per-frame START_OF_FRAME sequence header carries dims + total block
|
||||||
|
// count + the VUI bits (chroma 4:2:0, BT.709/BT.2020, limited/full).
|
||||||
|
// • With `USER_FLAG_CHUNK_ALIGNED` (Phase 4) the AU is a whole number of `shard_payload`-sized
|
||||||
|
// windows, each 4-byte-prefixed (used-len u16 LE + kind u16 LE): kind 0 = whole packets,
|
||||||
|
// 1/2/3 = FRAG chain for a packet bigger than one window. A missing shard of a partial frame
|
||||||
|
// arrives as an all-zero window (used = 0) → skipped, its blocks reconstruct as zeros
|
||||||
|
// (localized blur, the Phase-4 design intent). The reassembler enables partial delivery
|
||||||
|
// core-side automatically for PyroWave sessions.
|
||||||
|
// • Decode acceptance mirrors upstream decode_is_ready(allow_partial=true): a frame with no
|
||||||
|
// SOF or with no more than half its blocks is dropped rather than decoded to garbage.
|
||||||
|
//
|
||||||
|
// GPU structure per frame (mirroring pyrowave_decoder.cpp's barriers): one concurrent compute
|
||||||
|
// encoder with all ~42 dequant dispatches (each writes a distinct band layer — no intra-stage
|
||||||
|
// hazards), then one concurrent encoder per iDWT level (5) — encoder boundaries provide the
|
||||||
|
// write→sampled-read synchronization the Vulkan version expresses as pipeline barriers. The
|
||||||
|
// output is a ring of 4 plane sets (Y full-res + Cb/Cr half-res R8Unorm); ring depth plus
|
||||||
|
// same-queue hazard tracking keeps a set alive while the presenter still samples it (the same
|
||||||
|
// scheme as the Vulkan client's ring).
|
||||||
|
|
||||||
|
#if canImport(Metal)
|
||||||
|
import Foundation
|
||||||
|
import Metal
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
|
||||||
|
|
||||||
|
/// The per-(component, level, band) 32x32-block table — the exact Swift port of
|
||||||
|
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 4→0,
|
||||||
|
/// component 0→2 skipping level-0 chroma in 4:2:0, band (level==4 ? 0 : 1)→3) DEFINES the
|
||||||
|
/// global `block_index` space the wire packets address, so it must match the encoder exactly.
|
||||||
|
struct WaveletLayout {
|
||||||
|
static let decompositionLevels = 5
|
||||||
|
static let alignment = 32
|
||||||
|
static let minimumImageSize = 128
|
||||||
|
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
let alignedWidth: Int
|
||||||
|
let alignedHeight: Int
|
||||||
|
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||||
|
/// band not coded (level-0 chroma in 4:2:0).
|
||||||
|
let blockMeta: [[[(offset: Int, stride: Int)]]]
|
||||||
|
let blockCount32: Int
|
||||||
|
|
||||||
|
/// Band-image extent at `level` — mip `level` of the (aligned/2)-sized coefficient image.
|
||||||
|
/// Exact halving: the aligned dims are 32-aligned, so /2 is 16-aligned and survives 4 shifts.
|
||||||
|
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||||
|
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
||||||
|
|
||||||
|
init(width: Int, height: Int) {
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
let align = { (v: Int) in
|
||||||
|
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||||
|
}
|
||||||
|
alignedWidth = align(width)
|
||||||
|
alignedHeight = align(height)
|
||||||
|
|
||||||
|
var meta = [[[(offset: Int, stride: Int)]]](
|
||||||
|
repeating: [[(offset: Int, stride: Int)]](
|
||||||
|
repeating: [(offset: Int, stride: Int)](repeating: (-1, 0), count: 4),
|
||||||
|
count: Self.decompositionLevels),
|
||||||
|
count: 3)
|
||||||
|
var count32 = 0
|
||||||
|
let aw = alignedWidth
|
||||||
|
let ah = alignedHeight
|
||||||
|
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||||
|
for component in 0..<3 {
|
||||||
|
if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
|
||||||
|
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||||
|
let levelW = (aw / 2) >> level
|
||||||
|
let levelH = (ah / 2) >> level
|
||||||
|
let blocksX8 = (levelW + 7) / 8
|
||||||
|
let blocksY8 = (levelH + 7) / 8
|
||||||
|
let blocksX32 = (levelW + 31) / 32
|
||||||
|
meta[component][level][band] = (count32, blocksX32)
|
||||||
|
// accumulate_block_mapping's 32x32 count.
|
||||||
|
count32 += ((blocksX8 + 3) / 4) * ((blocksY8 + 3) / 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blockMeta = meta
|
||||||
|
blockCount32 = count32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed frame, CPU side: the per-block payload offset table + the flat payload words the
|
||||||
|
/// dequant kernel consumes (packet words INCLUDING each 8-byte header, as upstream uploads
|
||||||
|
/// them), plus the sequence header's facts.
|
||||||
|
struct ParsedWaveletFrame {
|
||||||
|
var layout: WaveletLayout
|
||||||
|
/// Per 32x32 block: u32 word offset into `payload`, or UInt32.max = block missing.
|
||||||
|
var offsets: [UInt32]
|
||||||
|
var payload: [UInt32]
|
||||||
|
var totalBlocks: Int
|
||||||
|
var decodedBlocks: Int
|
||||||
|
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||||
|
var bt2020: Bool
|
||||||
|
var fullRange: Bool
|
||||||
|
|
||||||
|
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
||||||
|
/// BT.709 limited (the host's fixed contract), but the sequence header signals it, so honor
|
||||||
|
/// what it says.
|
||||||
|
var cscSignal: CscRows.Signal {
|
||||||
|
CscRows.Signal(matrix: bt2020 ? 9 : 1, fullRange: fullRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WaveletBitstream {
|
||||||
|
/// Window kinds of the chunk-aligned framing (host WIN_* constants).
|
||||||
|
private static let winPacked: UInt16 = 0
|
||||||
|
private static let winFragFirst: UInt16 = 1
|
||||||
|
private static let winFragCont: UInt16 = 2
|
||||||
|
private static let winFragLast: UInt16 = 3
|
||||||
|
|
||||||
|
/// Parse one AU into the dequant kernel's inputs. `windowSize` > 0 with `chunkAligned`
|
||||||
|
/// walks the Phase-4 shard-window framing first; otherwise the AU is one packet stream.
|
||||||
|
/// nil = drop the frame (malformed, no SOF, or not enough blocks survived loss to be worth
|
||||||
|
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||||
|
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||||
|
var state = ParseState()
|
||||||
|
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||||
|
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let count = raw.count
|
||||||
|
if chunkAligned, windowSize >= 8 {
|
||||||
|
// Whole windows only; a trailing partial window would be a framing bug.
|
||||||
|
guard count % windowSize == 0 else { return false }
|
||||||
|
var frag: [UInt8] = []
|
||||||
|
var fragLive = false
|
||||||
|
var pos = 0
|
||||||
|
while pos < count {
|
||||||
|
let win = UnsafeBufferPointer(start: base + pos, count: windowSize)
|
||||||
|
pos += windowSize
|
||||||
|
let used = Int(win[0]) | (Int(win[1]) << 8)
|
||||||
|
let kind = UInt16(win[2]) | (UInt16(win[3]) << 8)
|
||||||
|
// A zeroed (missing) shard or an overrun drops the window AND breaks any
|
||||||
|
// fragment chain riding across it (mirrors video_pyrowave.rs push_window).
|
||||||
|
guard used > 0, 4 + used <= windowSize else {
|
||||||
|
frag.removeAll(keepingCapacity: true)
|
||||||
|
fragLive = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let body = UnsafeBufferPointer(start: win.baseAddress! + 4, count: used)
|
||||||
|
switch kind {
|
||||||
|
case winPacked:
|
||||||
|
frag.removeAll(keepingCapacity: true)
|
||||||
|
fragLive = false
|
||||||
|
guard state.pushPackets(body) else { return false }
|
||||||
|
case winFragFirst:
|
||||||
|
frag.removeAll(keepingCapacity: true)
|
||||||
|
frag.append(contentsOf: body)
|
||||||
|
fragLive = true
|
||||||
|
case winFragCont:
|
||||||
|
if fragLive { frag.append(contentsOf: body) }
|
||||||
|
case winFragLast:
|
||||||
|
if fragLive {
|
||||||
|
frag.append(contentsOf: body)
|
||||||
|
let ok = frag.withUnsafeBufferPointer { state.pushPackets($0) }
|
||||||
|
guard ok else { return false }
|
||||||
|
}
|
||||||
|
frag.removeAll(keepingCapacity: true)
|
||||||
|
fragLive = false
|
||||||
|
default:
|
||||||
|
frag.removeAll(keepingCapacity: true)
|
||||||
|
fragLive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return state.pushPackets(UnsafeBufferPointer(start: base, count: count))
|
||||||
|
}
|
||||||
|
guard ok, let frame = state.finish() else { return nil }
|
||||||
|
// Upstream decode_is_ready(allow_partial=true): with no SOF the frame is undecodable;
|
||||||
|
// at half the blocks or fewer it is presumed garbage.
|
||||||
|
guard frame.totalBlocks > 0, frame.decodedBlocks > frame.totalBlocks / 2 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming packet-walk state (pyrowave_decoder.cpp push_packet + decode_packet). The
|
||||||
|
/// SOF sequence header arrives first in every host AU, which fixes the dims → layout →
|
||||||
|
/// offset-table size before any coefficient packet lands; a coefficient packet before the
|
||||||
|
/// SOF (its window was lost) is skipped — its block just stays missing.
|
||||||
|
private struct ParseState {
|
||||||
|
var layout: WaveletLayout?
|
||||||
|
var offsets: [UInt32] = []
|
||||||
|
var payload: [UInt32] = []
|
||||||
|
var totalBlocks = 0
|
||||||
|
var decodedBlocks = 0
|
||||||
|
var bt2020 = false
|
||||||
|
var fullRange = false
|
||||||
|
var sawSOF = false
|
||||||
|
|
||||||
|
mutating func pushPackets(_ buf: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||||
|
guard let base = buf.baseAddress else { return true }
|
||||||
|
var pos = 0
|
||||||
|
let count = buf.count
|
||||||
|
while count - pos >= 8 {
|
||||||
|
let word0 = loadWord(base, pos)
|
||||||
|
let word1 = loadWord(base, pos + 4)
|
||||||
|
let extended = (word0 >> 31) & 1
|
||||||
|
if extended != 0 {
|
||||||
|
// BitstreamSequenceHeader: w-1[0:14] h-1[14:28] seq[28:31] ext[31];
|
||||||
|
// total[0:24] code[24:26] chroma[26] prim[27] trc[28] mtx[29] range[30]
|
||||||
|
// siting[31].
|
||||||
|
let code = (word1 >> 24) & 0x3
|
||||||
|
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
||||||
|
let chromaRes = (word1 >> 26) & 1
|
||||||
|
guard chromaRes == 0 else { return false } // host contract: 4:2:0
|
||||||
|
let w = Int(word0 & 0x3fff) + 1
|
||||||
|
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||||
|
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
||||||
|
if sawSOF {
|
||||||
|
// One frame, one geometry — a second SOF must agree.
|
||||||
|
guard layout?.width == w, layout?.height == h else { return false }
|
||||||
|
} else {
|
||||||
|
sawSOF = true
|
||||||
|
let l = WaveletLayout(width: w, height: h)
|
||||||
|
layout = l
|
||||||
|
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||||
|
payload.reserveCapacity(64 * 1024 / 4)
|
||||||
|
totalBlocks = Int(word1 & 0xff_ffff)
|
||||||
|
bt2020 = (word1 >> 29) & 1 != 0
|
||||||
|
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||||
|
}
|
||||||
|
pos += 8
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// BitstreamHeader: ballot[0:16] payload_words[16:28] seq[28:31] ext[31];
|
||||||
|
// quant_code[0:8] block_index[8:32]. payload_words counts u32s INCLUDING the
|
||||||
|
// 8-byte header.
|
||||||
|
let payloadWords = Int((word0 >> 16) & 0xfff)
|
||||||
|
guard payloadWords >= 2, pos + payloadWords * 4 <= count else { return false }
|
||||||
|
let blockIndex = Int(word1 >> 8)
|
||||||
|
if let layout, blockIndex < layout.blockCount32 {
|
||||||
|
// First write wins (duplicate packets are ignored, like upstream).
|
||||||
|
if offsets[blockIndex] == .max {
|
||||||
|
offsets[blockIndex] = UInt32(payload.count)
|
||||||
|
decodedBlocks += 1
|
||||||
|
payload.reserveCapacity(payload.count + payloadWords)
|
||||||
|
for w in 0..<payloadWords {
|
||||||
|
payload.append(loadWord(base, pos + w * 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if layout != nil {
|
||||||
|
return false // out-of-bounds block index — corrupt stream
|
||||||
|
}
|
||||||
|
// No layout yet (SOF lost): skip the packet, the block stays missing.
|
||||||
|
pos += payloadWords * 4
|
||||||
|
}
|
||||||
|
// In the windowed framing, `used` delimits exactly; dense AUs must also consume
|
||||||
|
// fully (upstream errors on trailing bytes).
|
||||||
|
return pos == count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadWord(_ base: UnsafePointer<UInt8>, _ offset: Int) -> UInt32 {
|
||||||
|
UInt32(base[offset])
|
||||||
|
| (UInt32(base[offset + 1]) << 8)
|
||||||
|
| (UInt32(base[offset + 2]) << 16)
|
||||||
|
| (UInt32(base[offset + 3]) << 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() -> ParsedWaveletFrame? {
|
||||||
|
guard let layout else { return nil }
|
||||||
|
return ParsedWaveletFrame(
|
||||||
|
layout: layout, offsets: offsets, payload: payload,
|
||||||
|
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
||||||
|
bt2020: bt2020, fullRange: fullRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded frame's output planes, handed to the presenter's planar render path. The
|
||||||
|
/// textures belong to the decoder's ring — ring depth (4) plus same-queue hazard tracking keep
|
||||||
|
/// them valid while referenced. Public because it rides inside `ReadyImage`.
|
||||||
|
public struct WaveletPlanes: @unchecked Sendable {
|
||||||
|
public let y: MTLTexture
|
||||||
|
public let cb: MTLTexture
|
||||||
|
public let cr: MTLTexture
|
||||||
|
public let csc: CscUniform
|
||||||
|
public var width: Int { y.width }
|
||||||
|
public var height: Int { y.height }
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class MetalWaveletDecoder {
|
||||||
|
/// Matches the Vulkan client's ring: deep enough that a slot is never rewritten while the
|
||||||
|
/// presenter still samples it in practice; same-queue hazard tracking is the hard backstop.
|
||||||
|
private static let ringDepth = 4
|
||||||
|
|
||||||
|
/// Device-capability gate for advertisement (SessionModel) and the settings picker: the
|
||||||
|
/// dequant kernel needs simdgroup prefix sums with its 16 header lanes inside one
|
||||||
|
/// simdgroup, so compile the real kernels once and check the pipeline facts. Apple6 (A13)
|
||||||
|
/// and every Mac2 device pass the family check; the compile probe is authoritative.
|
||||||
|
public static let supported: Bool = {
|
||||||
|
guard let device = MTLCreateSystemDefaultDevice() else { return false }
|
||||||
|
guard device.supportsFamily(.apple6) || device.supportsFamily(.mac2) else { return false }
|
||||||
|
do {
|
||||||
|
let lib = try device.makeLibrary(source: waveletShaderSource, options: nil)
|
||||||
|
guard let dequant = lib.makeFunction(name: "wavelet_dequant") else { return false }
|
||||||
|
let p = try device.makeComputePipelineState(function: dequant)
|
||||||
|
var shift = false
|
||||||
|
let fc = MTLFunctionConstantValues()
|
||||||
|
fc.setConstantValue(&shift, type: .bool, index: 0)
|
||||||
|
_ = try lib.makeFunction(name: "idwt", constantValues: fc)
|
||||||
|
return p.threadExecutionWidth >= 16 && p.maxTotalThreadsPerThreadgroup >= 128
|
||||||
|
} catch {
|
||||||
|
waveletLog.info("pyrowave probe: kernels rejected (\(error, privacy: .public))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let device: MTLDevice
|
||||||
|
private let queue: MTLCommandQueue
|
||||||
|
private let dequantPipeline: MTLComputePipelineState
|
||||||
|
private let idwtPipeline: MTLComputePipelineState
|
||||||
|
private let idwtShiftPipeline: MTLComputePipelineState
|
||||||
|
private let mirrorSampler: MTLSamplerState
|
||||||
|
|
||||||
|
// Size-dependent state, rebuilt when the SOF dims change (this is also the mid-stream
|
||||||
|
// Reconfigure/resize path — the wavelet decoder is fixed-size per geometry).
|
||||||
|
private var layout: WaveletLayout?
|
||||||
|
/// coefficients[component][level]: 4-slice R16Float (levels 0–1) / R32Float (levels 2–4)
|
||||||
|
/// texture2d_array — the band images (precision-1 split, see MetalWaveletShaders).
|
||||||
|
private var coefficients: [[MTLTexture]] = []
|
||||||
|
/// llViews[component][level]: slice-0 (LL band) 2D write view of `coefficients` — the iDWT
|
||||||
|
/// output target chaining level L+1 into level L.
|
||||||
|
private var llViews: [[MTLTexture]] = []
|
||||||
|
|
||||||
|
private struct Slot {
|
||||||
|
var y: MTLTexture
|
||||||
|
var cb: MTLTexture
|
||||||
|
var cr: MTLTexture
|
||||||
|
var offsets: MTLBuffer
|
||||||
|
var payload: MTLBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
private var slots: [Slot] = []
|
||||||
|
private var nextSlot = 0
|
||||||
|
|
||||||
|
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
||||||
|
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
||||||
|
var decodedSize: (width: Int, height: Int)? {
|
||||||
|
layout.map { ($0.width, $0.height) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pump thread owns `decode`; everything mutable is confined to it.
|
||||||
|
init?(device: MTLDevice, queue: MTLCommandQueue) {
|
||||||
|
self.device = device
|
||||||
|
self.queue = queue
|
||||||
|
do {
|
||||||
|
let lib = try device.makeLibrary(source: waveletShaderSource, options: nil)
|
||||||
|
guard let dequantFn = lib.makeFunction(name: "wavelet_dequant") else { return nil }
|
||||||
|
dequantPipeline = try device.makeComputePipelineState(function: dequantFn)
|
||||||
|
var shift = false
|
||||||
|
let fcOff = MTLFunctionConstantValues()
|
||||||
|
fcOff.setConstantValue(&shift, type: .bool, index: 0)
|
||||||
|
idwtPipeline = try device.makeComputePipelineState(
|
||||||
|
function: try lib.makeFunction(name: "idwt", constantValues: fcOff))
|
||||||
|
shift = true
|
||||||
|
let fcOn = MTLFunctionConstantValues()
|
||||||
|
fcOn.setConstantValue(&shift, type: .bool, index: 0)
|
||||||
|
idwtShiftPipeline = try device.makeComputePipelineState(
|
||||||
|
function: try lib.makeFunction(name: "idwt", constantValues: fcOn))
|
||||||
|
} catch {
|
||||||
|
waveletLog.error("pyrowave: pipeline build failed (\(error, privacy: .public))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard dequantPipeline.threadExecutionWidth >= 16,
|
||||||
|
dequantPipeline.maxTotalThreadsPerThreadgroup >= 128
|
||||||
|
else { return nil }
|
||||||
|
// Upstream's mirror_repeat_sampler: mirrored repeat, NEAREST everything, normalized
|
||||||
|
// coords — the idwt gather footprint + coordinate nudge depend on exactly this.
|
||||||
|
let samp = MTLSamplerDescriptor()
|
||||||
|
samp.sAddressMode = .mirrorRepeat
|
||||||
|
samp.tAddressMode = .mirrorRepeat
|
||||||
|
samp.minFilter = .nearest
|
||||||
|
samp.magFilter = .nearest
|
||||||
|
samp.mipFilter = .notMipmapped
|
||||||
|
samp.normalizedCoordinates = true
|
||||||
|
guard let sampler = device.makeSamplerState(descriptor: samp) else { return nil }
|
||||||
|
mirrorSampler = sampler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one AU. Synchronous CPU parse + async GPU decode: returns false when the frame
|
||||||
|
/// was dropped (malformed / SOF lost / not enough blocks); on true, `completion` fires on a
|
||||||
|
/// Metal callback thread once the planes are decoded (nil = the GPU pass errored).
|
||||||
|
/// PUMP THREAD only.
|
||||||
|
func decode(
|
||||||
|
au: Data, chunkAligned: Bool, windowSize: Int,
|
||||||
|
completion: @escaping @Sendable (WaveletPlanes?) -> Void
|
||||||
|
) -> Bool {
|
||||||
|
guard
|
||||||
|
let frame = WaveletBitstream.parse(
|
||||||
|
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
||||||
|
guard rebuild(layout: frame.layout) else { return false }
|
||||||
|
}
|
||||||
|
guard let layout, !slots.isEmpty else { return false }
|
||||||
|
|
||||||
|
var slot = slots[nextSlot]
|
||||||
|
// Grow the payload buffer to the frame (+16-byte zeroed guard: the kernel's 64-bit
|
||||||
|
// sign-window load and eager plane-byte prefetch may read past the payload end —
|
||||||
|
// upstream pads its Vulkan buffer for exactly this).
|
||||||
|
let payloadBytes = frame.payload.count * 4
|
||||||
|
if slot.payload.length < payloadBytes + 16 {
|
||||||
|
guard
|
||||||
|
let grown = device.makeBuffer(
|
||||||
|
length: max(64 * 1024, (payloadBytes + 16) * 2), options: .storageModeShared)
|
||||||
|
else { return false }
|
||||||
|
slot.payload = grown
|
||||||
|
slots[nextSlot] = slot
|
||||||
|
}
|
||||||
|
frame.offsets.withUnsafeBytes { src in
|
||||||
|
slot.offsets.contents().copyMemory(
|
||||||
|
from: src.baseAddress!, byteCount: min(src.count, slot.offsets.length))
|
||||||
|
}
|
||||||
|
frame.payload.withUnsafeBytes { src in
|
||||||
|
slot.payload.contents().copyMemory(from: src.baseAddress!, byteCount: src.count)
|
||||||
|
}
|
||||||
|
memset(slot.payload.contents() + payloadBytes, 0, 16)
|
||||||
|
|
||||||
|
guard let cmd = queue.makeCommandBuffer() else { return false }
|
||||||
|
|
||||||
|
// Stage 1: dequant — every (component, level, band) block grid in one concurrent
|
||||||
|
// encoder (each dispatch writes its own band layer; no intra-stage hazards, exactly
|
||||||
|
// like the barrier-free Vulkan dispatch loop).
|
||||||
|
guard let dequant = cmd.makeComputeCommandEncoder(dispatchType: .concurrent) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
dequant.label = "pyrowave dequant"
|
||||||
|
dequant.setComputePipelineState(dequantPipeline)
|
||||||
|
dequant.setBuffer(slot.offsets, offset: 0, index: 0)
|
||||||
|
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||||
|
for level in 0..<WaveletLayout.decompositionLevels {
|
||||||
|
for component in 0..<3 {
|
||||||
|
if level == 0 && component != 0 { continue } // 4:2:0
|
||||||
|
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||||
|
let meta = layout.blockMeta[component][level][band]
|
||||||
|
let w = layout.levelWidth(level)
|
||||||
|
let h = layout.levelHeight(level)
|
||||||
|
var regs = DequantRegisters(
|
||||||
|
resolution: SIMD2(Int32(w), Int32(h)),
|
||||||
|
outputLayer: Int32(band),
|
||||||
|
blockOffset32x32: Int32(meta.offset),
|
||||||
|
blockStride32x32: Int32(meta.stride))
|
||||||
|
dequant.setTexture(coefficients[component][level], index: 0)
|
||||||
|
dequant.setBytes(
|
||||||
|
®s, length: MemoryLayout<DequantRegisters>.stride, index: 2)
|
||||||
|
dequant.dispatchThreadgroups(
|
||||||
|
MTLSize(width: (w + 31) / 32, height: (h + 31) / 32, depth: 1),
|
||||||
|
threadsPerThreadgroup: MTLSize(width: 128, height: 1, depth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dequant.endEncoding()
|
||||||
|
|
||||||
|
// Stage 2: iDWT, coarsest level in — one encoder per level; the encoder boundary is
|
||||||
|
// the write→sampled-read barrier chaining each level's LL into the next.
|
||||||
|
for inputLevel in stride(from: WaveletLayout.decompositionLevels - 1, through: 0, by: -1) {
|
||||||
|
guard let idwt = cmd.makeComputeCommandEncoder(dispatchType: .concurrent) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
idwt.label = "pyrowave idwt L\(inputLevel)"
|
||||||
|
idwt.setSamplerState(mirrorSampler, index: 0)
|
||||||
|
// Resolution rides TRANSPOSED (the kernel transposes on load and store).
|
||||||
|
let rx = layout.levelHeight(inputLevel)
|
||||||
|
let ry = layout.levelWidth(inputLevel)
|
||||||
|
var regs = IdwtRegisters(
|
||||||
|
resolution: SIMD2(Int32(rx), Int32(ry)),
|
||||||
|
invResolution: SIMD2(1.0 / Float(rx), 1.0 / Float(ry)))
|
||||||
|
idwt.setBytes(®s, length: MemoryLayout<IdwtRegisters>.stride, index: 0)
|
||||||
|
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||||
|
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||||
|
if inputLevel == 0 {
|
||||||
|
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
||||||
|
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||||
|
idwt.setTexture(coefficients[0][0], index: 0)
|
||||||
|
idwt.setTexture(slot.y, index: 1)
|
||||||
|
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||||
|
} else {
|
||||||
|
for component in 0..<3 {
|
||||||
|
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
||||||
|
if component != 0 && inputLevel == 1 {
|
||||||
|
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||||
|
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||||
|
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||||
|
} else {
|
||||||
|
idwt.setComputePipelineState(idwtPipeline)
|
||||||
|
idwt.setTexture(llViews[component][inputLevel - 1], index: 1)
|
||||||
|
}
|
||||||
|
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idwt.endEncoding()
|
||||||
|
}
|
||||||
|
|
||||||
|
let planes = WaveletPlanes(
|
||||||
|
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||||
|
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
||||||
|
cmd.addCompletedHandler { buffer in
|
||||||
|
completion(buffer.error == nil ? planes : nil)
|
||||||
|
}
|
||||||
|
cmd.commit()
|
||||||
|
nextSlot = (nextSlot + 1) % Self.ringDepth
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
||||||
|
/// resize path: a Reconfigure shows up here as new SOF dims.
|
||||||
|
private func rebuild(layout newLayout: WaveletLayout) -> Bool {
|
||||||
|
waveletLog.info(
|
||||||
|
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
|
||||||
|
var coeff: [[MTLTexture]] = []
|
||||||
|
var lls: [[MTLTexture]] = []
|
||||||
|
for component in 0..<3 {
|
||||||
|
var perLevel: [MTLTexture] = []
|
||||||
|
var perLevelLL: [MTLTexture] = []
|
||||||
|
for level in 0..<WaveletLayout.decompositionLevels {
|
||||||
|
let desc = MTLTextureDescriptor()
|
||||||
|
desc.textureType = .type2DArray
|
||||||
|
desc.arrayLength = 4
|
||||||
|
// Upstream precision 1: fp16 storage for the two finest levels, fp32 for the
|
||||||
|
// coarse levels whose values feed every later reconstruction step.
|
||||||
|
desc.pixelFormat = level < 2 ? .r16Float : .r32Float
|
||||||
|
desc.width = newLayout.levelWidth(level)
|
||||||
|
desc.height = newLayout.levelHeight(level)
|
||||||
|
desc.usage = [.shaderRead, .shaderWrite]
|
||||||
|
desc.storageMode = .private
|
||||||
|
guard let tex = device.makeTexture(descriptor: desc) else { return false }
|
||||||
|
tex.label = "pyrowave coeff c\(component) L\(level)"
|
||||||
|
guard
|
||||||
|
let ll = tex.makeTextureView(
|
||||||
|
pixelFormat: desc.pixelFormat, textureType: .type2D,
|
||||||
|
levels: 0..<1, slices: 0..<1)
|
||||||
|
else { return false }
|
||||||
|
ll.label = "pyrowave LL c\(component) L\(level)"
|
||||||
|
perLevel.append(tex)
|
||||||
|
perLevelLL.append(ll)
|
||||||
|
}
|
||||||
|
coeff.append(perLevel)
|
||||||
|
lls.append(perLevelLL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newSlots: [Slot] = []
|
||||||
|
for i in 0..<Self.ringDepth {
|
||||||
|
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||||
|
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||||
|
pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
|
||||||
|
desc.usage = [.shaderRead, .shaderWrite]
|
||||||
|
desc.storageMode = .private
|
||||||
|
let t = self.device.makeTexture(descriptor: desc)
|
||||||
|
t?.label = name
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||||
|
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
||||||
|
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
||||||
|
let offsets = device.makeBuffer(
|
||||||
|
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||||
|
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||||
|
else { return false }
|
||||||
|
newSlots.append(Slot(y: y, cb: cb, cr: cr, offsets: offsets, payload: payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
coefficients = coeff
|
||||||
|
llViews = lls
|
||||||
|
slots = newSlots
|
||||||
|
nextSlot = 0
|
||||||
|
layout = newLayout
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSL-side layouts (MetalWaveletShaders.swift) — keep in lockstep.
|
||||||
|
private struct DequantRegisters {
|
||||||
|
var resolution: SIMD2<Int32>
|
||||||
|
var outputLayer: Int32
|
||||||
|
var blockOffset32x32: Int32
|
||||||
|
var blockStride32x32: Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct IdwtRegisters {
|
||||||
|
var resolution: SIMD2<Int32>
|
||||||
|
var invResolution: SIMD2<Float>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
// PyroWave decode compute kernels — the Metal port of the vendored Vulkan shaders
|
||||||
|
// (crates/pyrowave-sys/vendor/pyrowave/shaders/wavelet_dequant.comp + idwt.comp, upstream pin
|
||||||
|
// 509e4f88, MIT © 2025 Hans-Kristian Arntzen). Runtime-compiled Swift strings per client
|
||||||
|
// convention (no metallib build step — see GamepadChrome.swift's rationale); these are the
|
||||||
|
// client's first compute pipelines.
|
||||||
|
//
|
||||||
|
// Port notes (design/pyrowave-codec-plan.md §4.7):
|
||||||
|
// • Only the STORAGE_MODE 0 path exists: MSL device pointers replace the 8/16-bit-storage SSBO
|
||||||
|
// aliases; the texel-buffer (mode 1) and linear-image (mode 2) fallbacks are non-Apple IHV
|
||||||
|
// workarounds and are dropped, as is the fragment-iDWT path (Mali/Adreno only).
|
||||||
|
// • Subgroup ops map 1:1: subgroupInclusiveAdd → simd_prefix_inclusive_sum, and the fixed
|
||||||
|
// 32-wide Apple simdgroups take the GLSL's `SubgroupSize <= 32` scan branch; the shuffle-up
|
||||||
|
// and LDS fallbacks for exotic wave sizes are dead code here. The dequant kernel needs the
|
||||||
|
// 16 header lanes inside ONE simdgroup — MetalWaveletDecoder's probe enforces
|
||||||
|
// threadExecutionWidth >= 16.
|
||||||
|
// • Precision matches upstream's desktop default (PYROWAVE_PRECISION=1): float arithmetic,
|
||||||
|
// half2 threadgroup storage; the coefficient textures are R16Float for DWT levels 0–1 and
|
||||||
|
// R32Float for levels 2–4 (the low-res levels feed long reconstruction chains — upstream
|
||||||
|
// keeps them fp32 for exactly that reason).
|
||||||
|
// • The gather + mirrored-repeat addressing in idwt is the precision-sensitive spot (upstream
|
||||||
|
// fought a Mali compiler bug there); the golden-frame PSNR fixtures are the guard.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
let waveletShaderSource = """
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Shared helpers (dwt_swizzle.h / constants.h / dwt_quant_scale.h)
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static inline int2 unswizzle8x8(uint index)
|
||||||
|
{
|
||||||
|
uint y = extract_bits(index, 0, 1);
|
||||||
|
uint x = extract_bits(index, 1, 2);
|
||||||
|
y |= extract_bits(index, 3, 2) << 1;
|
||||||
|
x |= extract_bits(index, 5, 1) << 2;
|
||||||
|
return int2(int(x), int(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLSL bitfieldExtract(x, 0, n) where n may be 0; MSL extract_bits(bits=0) is not guaranteed
|
||||||
|
// to return 0, so mask explicitly.
|
||||||
|
static inline uint mask_lo(uint x, int n)
|
||||||
|
{
|
||||||
|
return (n <= 0) ? 0u : (x & (0xffffffffu >> (32 - n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pyrowave_common.hpp decode_quant: custom FP formulation, MaxScaleExp = 4.
|
||||||
|
static inline float decode_quant(uint quant_code)
|
||||||
|
{
|
||||||
|
int e = 4 - int(quant_code >> 3);
|
||||||
|
int m = int(quant_code) & 0x7;
|
||||||
|
return (1.0f / (8.0f * 1024.0f * 1024.0f)) * float((8 + m) * (1 << (20 + e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// dwt_quant_scale.h: per-8x8 quant scale, min 0.25, max ~2.2.
|
||||||
|
static inline float decode_quant_scale(uint code)
|
||||||
|
{
|
||||||
|
return float(code) / 8.0f + 0.25f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// constants.h
|
||||||
|
constant int QUANT_SCALE_OFFSET = 20;
|
||||||
|
constant int QUANT_SCALE_BITS = 4;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// wavelet_dequant — one 128-thread threadgroup decodes one 32x32 coefficient block
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct DequantRegisters {
|
||||||
|
int2 resolution;
|
||||||
|
int output_layer;
|
||||||
|
int block_offset_32x32;
|
||||||
|
int block_stride_32x32;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DecodedPair { float4 col0; float4 col1; }; // GLSL mat2x4: m[j][i] -> colJ[i]
|
||||||
|
|
||||||
|
// Bit-plane magnitude decode for one thread's 4x2 coefficient group (decode_payload in the
|
||||||
|
// GLSL). `code_word` is the 8x8 block's 16-bit control word (2 bits of extra planes per 4x2
|
||||||
|
// group), `q_bits` the base plane count, `offset` the block's plane-payload start byte,
|
||||||
|
// `block_index` this thread's group (0..7). Nonzero magnitudes get the +0.5 deadzone
|
||||||
|
// reconstruction bias.
|
||||||
|
static DecodedPair decode_payload(const device uchar *payload_u8,
|
||||||
|
uint code_word, uint q_bits, uint offset, uint block_index)
|
||||||
|
{
|
||||||
|
DecodedPair m;
|
||||||
|
m.col0 = float4(0.0f);
|
||||||
|
m.col1 = float4(0.0f);
|
||||||
|
if (code_word == 0)
|
||||||
|
return m;
|
||||||
|
|
||||||
|
int bit_offset = 2 * int(block_index);
|
||||||
|
|
||||||
|
uint lsbs = code_word & 0x5555u;
|
||||||
|
uint msbs = code_word & 0xaaaau;
|
||||||
|
uint msbs_shift = msbs >> 1;
|
||||||
|
msbs |= msbs_shift;
|
||||||
|
|
||||||
|
uint byte_offset =
|
||||||
|
popcount(mask_lo(lsbs, bit_offset)) +
|
||||||
|
popcount(mask_lo(msbs, bit_offset)) +
|
||||||
|
q_bits * block_index + offset;
|
||||||
|
|
||||||
|
uint payload = uint(payload_u8[byte_offset]);
|
||||||
|
|
||||||
|
uint local_control_word = extract_bits(code_word, uint(bit_offset), 2);
|
||||||
|
int decoded_abs[8] = {0, 0, 0, 0, 0, 0, 0, 0};
|
||||||
|
int plane_iterations = int(q_bits + local_control_word);
|
||||||
|
|
||||||
|
for (int q = plane_iterations - 1; q >= 0; q--)
|
||||||
|
{
|
||||||
|
for (int b = 0; b < 8; b++)
|
||||||
|
{
|
||||||
|
int decoded = int(extract_bits(payload, uint(b), 1));
|
||||||
|
decoded_abs[b] = insert_bits(decoded_abs[b], decoded, uint(q), 1);
|
||||||
|
}
|
||||||
|
byte_offset++;
|
||||||
|
payload = uint(payload_u8[byte_offset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < 2; j++)
|
||||||
|
{
|
||||||
|
float v = float(decoded_abs[i * 2 + j]);
|
||||||
|
if (v != 0.0f)
|
||||||
|
v += 0.5f;
|
||||||
|
if (j == 0) m.col0[i] = v; else m.col1[i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
kernel void wavelet_dequant(
|
||||||
|
texture2d_array<float, access::write> uDequantImg [[texture(0)]],
|
||||||
|
const device uint *payload_offsets [[buffer(0)]],
|
||||||
|
const device uint *payload_u32 [[buffer(1)]],
|
||||||
|
constant DequantRegisters ®isters [[buffer(2)]],
|
||||||
|
uint3 wg_id [[threadgroup_position_in_grid]],
|
||||||
|
uint local_index [[thread_index_in_threadgroup]],
|
||||||
|
uint simd_lane [[thread_index_in_simdgroup]],
|
||||||
|
uint simd_group [[simdgroup_index_in_threadgroup]],
|
||||||
|
uint simd_size [[threads_per_simdgroup]])
|
||||||
|
{
|
||||||
|
// STORAGE_MODE 0's three aliased SSBO views over one buffer, as typed pointers.
|
||||||
|
const device ushort *payload_u16 = reinterpret_cast<const device ushort *>(payload_u32);
|
||||||
|
const device uchar *payload_u8 = reinterpret_cast<const device uchar *>(payload_u32);
|
||||||
|
|
||||||
|
threadgroup uint shared_sign_offset;
|
||||||
|
threadgroup uint shared_plane_byte_offsets[16];
|
||||||
|
threadgroup uint shared_sign_scan[128 / 4];
|
||||||
|
|
||||||
|
int block_index_32x32 = int(uint(registers.block_offset_32x32) +
|
||||||
|
wg_id.y * uint(registers.block_stride_32x32) +
|
||||||
|
wg_id.x);
|
||||||
|
|
||||||
|
uint block_local_index = extract_bits(local_index, 0, 3);
|
||||||
|
uint block_x = extract_bits(local_index, 3, 2);
|
||||||
|
uint block_y = extract_bits(local_index, 5, 2);
|
||||||
|
uint linear_block = block_y * 4 + block_x;
|
||||||
|
|
||||||
|
// Each thread individually decodes 8 values (a 4x2 group of its 8x8 block).
|
||||||
|
int2 local_coord = unswizzle8x8(block_local_index << 3);
|
||||||
|
|
||||||
|
int2 coord = int2(wg_id.xy) * 32;
|
||||||
|
coord += 8 * int2(int(block_x), int(block_y));
|
||||||
|
coord += local_coord;
|
||||||
|
|
||||||
|
uint offset_u32 = payload_offsets[block_index_32x32];
|
||||||
|
|
||||||
|
// Missing / lost block: zero coefficients (this is how a partial frame's holes decode).
|
||||||
|
if (offset_u32 == ~0u)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < 2; j++)
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
uDequantImg.write(float4(0.0f), uint2(coord + int2(i, j)), uint(registers.output_layer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint ballot = payload_u32[offset_u32] & 0xffffu;
|
||||||
|
uint q_code = payload_u32[offset_u32 + 1] & 0xffu;
|
||||||
|
|
||||||
|
// Threads 0..15 (one per 8x8 block, all inside simdgroup 0) prefix-scan the per-block
|
||||||
|
// plane-payload byte costs into shared_plane_byte_offsets, and lane 15 records where the
|
||||||
|
// sign bitstream starts.
|
||||||
|
if (local_index < 16)
|
||||||
|
{
|
||||||
|
uint control_word = 0;
|
||||||
|
uint q_bits = 0;
|
||||||
|
|
||||||
|
if (extract_bits(ballot, local_index, 1) != 0)
|
||||||
|
{
|
||||||
|
uint local_code_offset = popcount(mask_lo(ballot, int(local_index)));
|
||||||
|
control_word = uint(payload_u16[offset_u32 * 2 + 4 + local_code_offset]);
|
||||||
|
q_bits = uint(payload_u8[offset_u32 * 4 + 8 + popcount(ballot) * 2 + local_code_offset]) & 0xfu;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint lsbs = control_word & 0x5555u;
|
||||||
|
uint msbs = control_word & 0xaaaau;
|
||||||
|
uint msbs_shift = msbs >> 1;
|
||||||
|
msbs |= msbs_shift;
|
||||||
|
uint byte_cost = popcount(lsbs) + popcount(msbs) + q_bits * 8;
|
||||||
|
|
||||||
|
uint byte_scan = offset_u32 * 4 + 8 + 3 * popcount(ballot) + simd_prefix_inclusive_sum(byte_cost);
|
||||||
|
if (local_index == 15)
|
||||||
|
shared_sign_offset = 8 * byte_scan;
|
||||||
|
shared_plane_byte_offsets[local_index] = byte_scan - byte_cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
|
||||||
|
DecodedPair v;
|
||||||
|
int significant_count;
|
||||||
|
|
||||||
|
if (extract_bits(ballot, linear_block, 1) != 0)
|
||||||
|
{
|
||||||
|
uint local_code_offset = popcount(mask_lo(ballot, int(linear_block)));
|
||||||
|
|
||||||
|
uint control_word = uint(payload_u16[offset_u32 * 2 + 4 + local_code_offset]);
|
||||||
|
uint control_word2 = uint(payload_u8[offset_u32 * 4 + 8 + popcount(ballot) * 2 + local_code_offset]);
|
||||||
|
|
||||||
|
v = decode_payload(payload_u8, control_word, control_word2 & 0xfu,
|
||||||
|
shared_plane_byte_offsets[linear_block], block_local_index);
|
||||||
|
|
||||||
|
significant_count = 0;
|
||||||
|
for (int j = 0; j < 2; j++)
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
significant_count += int(((j == 0) ? v.col0[i] : v.col1[i]) != 0.0f);
|
||||||
|
|
||||||
|
float q = decode_quant(q_code);
|
||||||
|
float inv_scale = q * decode_quant_scale(extract_bits(control_word2, uint(QUANT_SCALE_OFFSET - 16), uint(QUANT_SCALE_BITS)));
|
||||||
|
|
||||||
|
v.col0 *= inv_scale;
|
||||||
|
v.col1 *= inv_scale;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
v.col0 = float4(0.0f);
|
||||||
|
v.col1 = float4(0.0f);
|
||||||
|
significant_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-threadgroup scan of significant-coefficient counts → each thread's first sign-bit
|
||||||
|
// position. Apple simdgroups are >= 16 wide, so this is the GLSL's `SubgroupSize <= 32`
|
||||||
|
// branch; the shuffle/LDS fallbacks are unnecessary.
|
||||||
|
int significant_scan = int(simd_prefix_inclusive_sum(uint(significant_count)));
|
||||||
|
if (simd_lane == simd_size - 1)
|
||||||
|
shared_sign_scan[simd_group] = uint(significant_scan);
|
||||||
|
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
uint num_simdgroups = (128 + simd_size - 1) / simd_size;
|
||||||
|
if (local_index < num_simdgroups)
|
||||||
|
shared_sign_scan[local_index] = simd_prefix_inclusive_sum(shared_sign_scan[local_index]);
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
|
||||||
|
uint sign_offset = shared_sign_offset + uint(significant_scan - significant_count);
|
||||||
|
if (simd_group != 0)
|
||||||
|
sign_offset += shared_sign_scan[simd_group - 1];
|
||||||
|
|
||||||
|
// Load 64 bits of sign stream and bit-align (may read one word past the payload — the
|
||||||
|
// buffer carries a 16-byte zeroed guard tail for exactly this).
|
||||||
|
uint sign_word = payload_u32[sign_offset / 32 + 0];
|
||||||
|
uint sign_word_upper = payload_u32[sign_offset / 32 + 1];
|
||||||
|
|
||||||
|
uint masked_sign_offset = sign_offset & 31u;
|
||||||
|
if (masked_sign_offset != 0)
|
||||||
|
{
|
||||||
|
sign_word >>= masked_sign_offset;
|
||||||
|
sign_word |= sign_word_upper << (32 - masked_sign_offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
int sign_counter = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < 2; j++)
|
||||||
|
{
|
||||||
|
float val = (j == 0) ? v.col0[i] : v.col1[i];
|
||||||
|
if (val != 0.0f)
|
||||||
|
{
|
||||||
|
val *= 1.0f - 2.0f * float(extract_bits(sign_word, uint(sign_counter), 1));
|
||||||
|
sign_counter++;
|
||||||
|
if (j == 0) v.col0[i] = val; else v.col1[i] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int j = 0; j < 2; j++)
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
uDequantImg.write(float4((j == 0) ? v.col0[i] : v.col1[i]),
|
||||||
|
uint2(coord + int2(i, j)), uint(registers.output_layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// idwt — inverse CDF 9/7; one 64-thread threadgroup reconstructs one 32x32 output tile from the
|
||||||
|
// four half-res band layers (LL/HL/LH/HH), with a 4-sample mirror apron. The caller passes the
|
||||||
|
// band-image resolution TRANSPOSED (the kernel transposes on load and store, so one kernel does
|
||||||
|
// both the horizontal and vertical passes).
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
constant bool DCShift [[function_constant(0)]];
|
||||||
|
|
||||||
|
struct IdwtRegisters {
|
||||||
|
int2 resolution;
|
||||||
|
float2 inv_resolution;
|
||||||
|
};
|
||||||
|
|
||||||
|
constant int APRON = 4;
|
||||||
|
constant int APRON_HALF = APRON / 2;
|
||||||
|
constant int BLOCK_SIZE = 32;
|
||||||
|
constant int BLOCK_SIZE_HALF = BLOCK_SIZE >> 1;
|
||||||
|
|
||||||
|
// CDF 9/7 lifting constants (dwt_common.h).
|
||||||
|
constant float ALPHA = -1.586134342059924f;
|
||||||
|
constant float BETA = -0.052980118572961f;
|
||||||
|
constant float GAMMA = 0.882911075530934f;
|
||||||
|
constant float DELTA = 0.443506852043971f;
|
||||||
|
constant float K = 1.230174104914001f;
|
||||||
|
constant float inv_K = 1.0f / 1.230174104914001f;
|
||||||
|
|
||||||
|
constant int SHARED_ROWS = (BLOCK_SIZE + 2 * APRON) / 2; // 20
|
||||||
|
constant int SHARED_COLS = (BLOCK_SIZE + 2 * APRON) + 1; // 41 (+1 avoids bank conflicts)
|
||||||
|
|
||||||
|
static inline float2 load_shared(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], int y, int x)
|
||||||
|
{
|
||||||
|
return float2(blk[y][x]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void store_shared(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], int y, int x, float2 v)
|
||||||
|
{
|
||||||
|
blk[y][x] = half2(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even/odd-phase coordinate nudge so mirrored-repeat gather reproduces JPEG2000 whole-sample
|
||||||
|
// mirroring at the image borders, then transpose (uv.yx) on load.
|
||||||
|
static inline float2 generate_mirror_uv(int2 coord, bool even_x, bool even_y,
|
||||||
|
int2 resolution, float2 inv_resolution)
|
||||||
|
{
|
||||||
|
coord.x -= int(even_x && coord.x < 0);
|
||||||
|
coord.y -= int(even_y && coord.y < 0);
|
||||||
|
coord += 1;
|
||||||
|
coord.x += int(!even_x && coord.x >= resolution.x);
|
||||||
|
coord.y += int(!even_y && coord.y >= resolution.y);
|
||||||
|
float2 uv = float2(coord) * inv_resolution;
|
||||||
|
return uv.yx;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void write_shared_4x4(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||||
|
int2 coord, float4 t0, float4 t1, float4 t2, float4 t3)
|
||||||
|
{
|
||||||
|
store_shared(blk, coord.y + 0, 2 * coord.x + 0, float2(t0.x, t2.x));
|
||||||
|
store_shared(blk, coord.y + 0, 2 * coord.x + 1, float2(t1.x, t3.x));
|
||||||
|
store_shared(blk, coord.y + 0, 2 * coord.x + 2, float2(t0.y, t2.y));
|
||||||
|
store_shared(blk, coord.y + 0, 2 * coord.x + 3, float2(t1.y, t3.y));
|
||||||
|
store_shared(blk, coord.y + 1, 2 * coord.x + 0, float2(t0.z, t2.z));
|
||||||
|
store_shared(blk, coord.y + 1, 2 * coord.x + 1, float2(t1.z, t3.z));
|
||||||
|
store_shared(blk, coord.y + 1, 2 * coord.x + 2, float2(t0.w, t2.w));
|
||||||
|
store_shared(blk, coord.y + 1, 2 * coord.x + 3, float2(t1.w, t3.w));
|
||||||
|
}
|
||||||
|
|
||||||
|
// textureGather(...).wxzy — Metal's gather returns the same counter-clockwise-from-(i0,j1)
|
||||||
|
// component order as Vulkan, so the reorder is identical.
|
||||||
|
static inline float4 gather_layer(texture2d_array<float, access::sample> tex, sampler smp,
|
||||||
|
float2 uv, uint layer)
|
||||||
|
{
|
||||||
|
float4 g = tex.gather(smp, uv, layer);
|
||||||
|
return float4(g.w, g.x, g.z, g.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void load_image_with_apron(texture2d_array<float, access::sample> tex, sampler smp,
|
||||||
|
threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||||
|
uint local_index, uint2 wg_id,
|
||||||
|
int2 resolution, float2 inv_resolution)
|
||||||
|
{
|
||||||
|
int2 base_coord = int2(wg_id) * BLOCK_SIZE_HALF - APRON_HALF;
|
||||||
|
int2 local_coord0 = 2 * unswizzle8x8(local_index);
|
||||||
|
int2 coord0 = base_coord + local_coord0;
|
||||||
|
|
||||||
|
// Band layers gathered in 0/2/1/3 order (LL/LH/HL/HH interleave for the 2x2 scatter).
|
||||||
|
float4 texels0 = gather_layer(tex, smp, generate_mirror_uv(coord0, true, true, resolution, inv_resolution), 0);
|
||||||
|
float4 texels1 = gather_layer(tex, smp, generate_mirror_uv(coord0, false, true, resolution, inv_resolution), 2);
|
||||||
|
float4 texels2 = gather_layer(tex, smp, generate_mirror_uv(coord0, true, false, resolution, inv_resolution), 1);
|
||||||
|
float4 texels3 = gather_layer(tex, smp, generate_mirror_uv(coord0, false, false, resolution, inv_resolution), 3);
|
||||||
|
write_shared_4x4(blk, local_coord0, texels0, texels1, texels2, texels3);
|
||||||
|
|
||||||
|
int2 local_coord_horiz = int2(BLOCK_SIZE_HALF + 2 * int(local_index % 2u), 2 * int(local_index / 2u));
|
||||||
|
if (local_coord_horiz.y < BLOCK_SIZE_HALF + 2 * APRON_HALF)
|
||||||
|
{
|
||||||
|
int2 c = base_coord + local_coord_horiz;
|
||||||
|
texels0 = gather_layer(tex, smp, generate_mirror_uv(c, true, true, resolution, inv_resolution), 0);
|
||||||
|
texels1 = gather_layer(tex, smp, generate_mirror_uv(c, false, true, resolution, inv_resolution), 2);
|
||||||
|
texels2 = gather_layer(tex, smp, generate_mirror_uv(c, true, false, resolution, inv_resolution), 1);
|
||||||
|
texels3 = gather_layer(tex, smp, generate_mirror_uv(c, false, false, resolution, inv_resolution), 3);
|
||||||
|
write_shared_4x4(blk, local_coord_horiz, texels0, texels1, texels2, texels3);
|
||||||
|
}
|
||||||
|
|
||||||
|
int2 local_coord_vert = local_coord_horiz.yx;
|
||||||
|
if (local_coord_vert.x < BLOCK_SIZE_HALF)
|
||||||
|
{
|
||||||
|
int2 c = base_coord + local_coord_vert;
|
||||||
|
texels0 = gather_layer(tex, smp, generate_mirror_uv(c, true, true, resolution, inv_resolution), 0);
|
||||||
|
texels1 = gather_layer(tex, smp, generate_mirror_uv(c, false, true, resolution, inv_resolution), 2);
|
||||||
|
texels2 = gather_layer(tex, smp, generate_mirror_uv(c, true, false, resolution, inv_resolution), 1);
|
||||||
|
texels3 = gather_layer(tex, smp, generate_mirror_uv(c, false, false, resolution, inv_resolution), 3);
|
||||||
|
write_shared_4x4(blk, local_coord_vert, texels0, texels1, texels2, texels3);
|
||||||
|
}
|
||||||
|
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void inverse_transform8x2(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], uint local_index)
|
||||||
|
{
|
||||||
|
const int SIZE = 8;
|
||||||
|
const int PADDED_SIZE = SIZE + 2 * APRON;
|
||||||
|
const int PADDED_SIZE_HALF = PADDED_SIZE / 2;
|
||||||
|
float2 values[PADDED_SIZE];
|
||||||
|
|
||||||
|
int2 local_coord = int2(8 * int(local_index % 4u), int(local_index / 4u));
|
||||||
|
|
||||||
|
for (int i = 0; i < PADDED_SIZE; i += 2)
|
||||||
|
{
|
||||||
|
float2 v0 = load_shared(blk, local_coord.y, local_coord.x + i + 0);
|
||||||
|
float2 v1 = load_shared(blk, local_coord.y, local_coord.x + i + 1);
|
||||||
|
values[i + 0] = v0 * K;
|
||||||
|
values[i + 1] = v1 * inv_K;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDF 9/7 inverse lifting steps.
|
||||||
|
for (int i = 2; i < PADDED_SIZE - 1; i += 2)
|
||||||
|
values[i] -= DELTA * (values[i - 1] + values[i + 1]);
|
||||||
|
for (int i = 3; i < PADDED_SIZE - 2; i += 2)
|
||||||
|
values[i] -= GAMMA * (values[i - 1] + values[i + 1]);
|
||||||
|
for (int i = 4; i < PADDED_SIZE - 3; i += 2)
|
||||||
|
values[i] -= BETA * (values[i - 1] + values[i + 1]);
|
||||||
|
for (int i = 5; i < PADDED_SIZE - 4; i += 2)
|
||||||
|
values[i] -= ALPHA * (values[i - 1] + values[i + 1]);
|
||||||
|
|
||||||
|
// Avoid WAR hazard.
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
|
||||||
|
for (int i = APRON_HALF; i < PADDED_SIZE_HALF - APRON_HALF; i++)
|
||||||
|
{
|
||||||
|
float2 a = values[2 * i + 0];
|
||||||
|
float2 b = values[2 * i + 1];
|
||||||
|
|
||||||
|
// Transpose the 2x2 block, transpose write.
|
||||||
|
float2 t0 = float2(a.x, b.x);
|
||||||
|
float2 t1 = float2(a.y, b.y);
|
||||||
|
|
||||||
|
int y_coord = (local_coord.x >> 1) + (i - APRON_HALF);
|
||||||
|
store_shared(blk, y_coord, 2 * local_coord.y + 0, t0);
|
||||||
|
store_shared(blk, y_coord, 2 * local_coord.y + 1, t1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void inverse_transform4x2(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||||
|
uint local_index, bool active_lane, int y_offset)
|
||||||
|
{
|
||||||
|
const int SIZE = 4;
|
||||||
|
const int PADDED_SIZE = SIZE + 2 * APRON;
|
||||||
|
const int PADDED_SIZE_HALF = PADDED_SIZE / 2;
|
||||||
|
float2 values[PADDED_SIZE];
|
||||||
|
|
||||||
|
int2 local_coord = int2(4 * int(local_index % 8u), int(local_index / 8u) + y_offset);
|
||||||
|
|
||||||
|
if (active_lane)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < PADDED_SIZE; i += 2)
|
||||||
|
{
|
||||||
|
float2 v0 = load_shared(blk, local_coord.y, local_coord.x + i + 0);
|
||||||
|
float2 v1 = load_shared(blk, local_coord.y, local_coord.x + i + 1);
|
||||||
|
values[i + 0] = v0 * K;
|
||||||
|
values[i + 1] = v1 * inv_K;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 2; i < PADDED_SIZE - 1; i += 2)
|
||||||
|
values[i] -= DELTA * (values[i - 1] + values[i + 1]);
|
||||||
|
for (int i = 3; i < PADDED_SIZE - 2; i += 2)
|
||||||
|
values[i] -= GAMMA * (values[i - 1] + values[i + 1]);
|
||||||
|
for (int i = 4; i < PADDED_SIZE - 3; i += 2)
|
||||||
|
values[i] -= BETA * (values[i - 1] + values[i + 1]);
|
||||||
|
for (int i = 5; i < PADDED_SIZE - 4; i += 2)
|
||||||
|
values[i] -= ALPHA * (values[i - 1] + values[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
|
||||||
|
if (active_lane)
|
||||||
|
{
|
||||||
|
for (int i = APRON_HALF; i < PADDED_SIZE_HALF - APRON_HALF; i++)
|
||||||
|
{
|
||||||
|
float2 a = values[2 * i + 0];
|
||||||
|
float2 b = values[2 * i + 1];
|
||||||
|
|
||||||
|
float2 t0 = float2(a.x, b.x);
|
||||||
|
float2 t1 = float2(a.y, b.y);
|
||||||
|
|
||||||
|
int y_coord = (local_coord.x >> 1) + (i - APRON_HALF);
|
||||||
|
store_shared(blk, y_coord, 2 * local_coord.y + 0, t0);
|
||||||
|
store_shared(blk, y_coord, 2 * local_coord.y + 1, t1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kernel void idwt(
|
||||||
|
texture2d_array<float, access::sample> uTexture [[texture(0)]],
|
||||||
|
texture2d<float, access::write> uOutput [[texture(1)]],
|
||||||
|
sampler uSampler [[sampler(0)]],
|
||||||
|
constant IdwtRegisters ®isters [[buffer(0)]],
|
||||||
|
uint3 wg_id [[threadgroup_position_in_grid]],
|
||||||
|
uint local_index [[thread_index_in_threadgroup]])
|
||||||
|
{
|
||||||
|
threadgroup half2 shared_block[SHARED_ROWS][SHARED_COLS];
|
||||||
|
|
||||||
|
load_image_with_apron(uTexture, uSampler, shared_block, local_index, wg_id.xy,
|
||||||
|
registers.resolution, registers.inv_resolution);
|
||||||
|
|
||||||
|
// Horizontal transform.
|
||||||
|
inverse_transform8x2(shared_block, local_index);
|
||||||
|
|
||||||
|
// Also need to transform the apron.
|
||||||
|
inverse_transform4x2(shared_block, local_index, local_index < 32, BLOCK_SIZE_HALF);
|
||||||
|
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
|
||||||
|
// Vertical transform.
|
||||||
|
inverse_transform8x2(shared_block, local_index);
|
||||||
|
|
||||||
|
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||||
|
|
||||||
|
int2 local_coord = unswizzle8x8(local_index);
|
||||||
|
|
||||||
|
for (int y = local_coord.y; y < BLOCK_SIZE_HALF; y += 8)
|
||||||
|
{
|
||||||
|
for (int x = local_coord.x; x < BLOCK_SIZE; x += 8)
|
||||||
|
{
|
||||||
|
float2 v = load_shared(shared_block, y, x);
|
||||||
|
if (DCShift)
|
||||||
|
v += 0.5f;
|
||||||
|
// Transposed store (wg_id.yx) — undoes the transpose-on-load; out-of-range writes
|
||||||
|
// at the aligned-size overhang are dropped by Metal (matching the Vulkan behavior).
|
||||||
|
int2 out0 = int2(2 * y + 0, x) + BLOCK_SIZE * int2(int(wg_id.y), int(wg_id.x));
|
||||||
|
int2 out1 = int2(2 * y + 1, x) + BLOCK_SIZE * int2(int(wg_id.y), int(wg_id.x));
|
||||||
|
uOutput.write(float4(v.x), uint2(out0));
|
||||||
|
uOutput.write(float4(v.y), uint2(out1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Swift wrapper around the punktfunk-core C ABI's post-loss re-anchor gate
|
||||||
|
// (`punktfunk_reanchor_gate_*`, ABI v6). The shared Rust gate (crates/punktfunk-core/src/reanchor.rs)
|
||||||
|
// is what the Linux/Windows desktop pump and the Android client use directly; the Swift clients reach
|
||||||
|
// it across the C ABI so the freeze-until-reanchor policy is defined ONCE for every platform.
|
||||||
|
//
|
||||||
|
// Why a freeze at all: after unrecoverable loss the host keeps sending delta frames that reference a
|
||||||
|
// picture the client never got. Hardware decoders (VideoToolbox included) don't reliably error on
|
||||||
|
// that — they CONCEAL, returning a gray/garbage frame with a success status. Presenting those is the
|
||||||
|
// visible "gray flash with motion" of the loss reports. The gate withholds concealed frames and holds
|
||||||
|
// the last good picture on glass until a PROVEN clean re-anchor lands — an IDR (wire `FLAG_SOF`), an
|
||||||
|
// RFI recovery anchor (`USER_FLAG_RECOVERY_ANCHOR`), or the 2nd of two intra-refresh recovery marks
|
||||||
|
// (`USER_FLAG_RECOVERY_POINT`) — with a bounded backstop so a lost re-anchor can never freeze forever.
|
||||||
|
// See punktfunk-planning design/client-reanchor-freeze-parity.md.
|
||||||
|
//
|
||||||
|
// Threading: one gate per session. Its calls arrive from two threads — the pump thread (`arm` on a
|
||||||
|
// frame-index gap / a submit failure, `poll` per iteration) and a VideoToolbox decode thread
|
||||||
|
// (`onDecoded` per decoded frame, `onNoOutput` on a decode error). The raw Rust gate is a plain
|
||||||
|
// struct behind an opaque pointer with no internal synchronization, so every call is serialized under
|
||||||
|
// `lock` here — the calls are cheap field updates, so contention is negligible. `@unchecked Sendable`:
|
||||||
|
// the lock enforces the contract.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PunktfunkCore
|
||||||
|
|
||||||
|
final class ReanchorGate: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
/// The opaque `ReanchorGate *`. `var` so `reseed` can swap it at session start. Never NULL
|
||||||
|
/// (`punktfunk_reanchor_gate_new` never returns NULL).
|
||||||
|
private var ptr: OpaquePointer
|
||||||
|
|
||||||
|
/// Seed the baseline with the connection's current `framesDropped` so the first `poll` doesn't
|
||||||
|
/// read the session's starting drop count as a fresh loss.
|
||||||
|
init(framesDropped: UInt64) {
|
||||||
|
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { punktfunk_reanchor_gate_free(ptr) }
|
||||||
|
|
||||||
|
/// Re-anchor the drop-count baseline to `framesDropped` for a (re)started session. The gate is
|
||||||
|
/// created in the pipeline's init (before a connection exists, seeded 0); `start` calls this once
|
||||||
|
/// the live connection's count is known so a mid-life connection's non-zero baseline isn't
|
||||||
|
/// mistaken for loss on the first poll.
|
||||||
|
func reseed(framesDropped: UInt64) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
punktfunk_reanchor_gate_free(ptr)
|
||||||
|
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge). Zeroes the
|
||||||
|
/// recovery-mark count and (re)sets the backstop deadline.
|
||||||
|
func arm() {
|
||||||
|
lock.lock()
|
||||||
|
punktfunk_reanchor_gate_arm(ptr)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||||
|
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||||
|
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||||
|
func onDecoded(flags: UInt32, decoderKeyframe: Bool = false) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var present = false
|
||||||
|
_ = punktfunk_reanchor_gate_on_decoded(ptr, flags, decoderKeyframe, &present)
|
||||||
|
return present
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A received AU produced no decoded frame (a VideoToolbox decode error). Returns true when the
|
||||||
|
/// no-output streak has tripped (the gate armed the freeze) and the caller should — throttled —
|
||||||
|
/// request a keyframe.
|
||||||
|
func onNoOutput() -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var requestKf = false
|
||||||
|
_ = punktfunk_reanchor_gate_on_no_output(ptr, &requestKf)
|
||||||
|
return requestKf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic fold of the session's `framesDropped` plus the overdue backstop. Returns true when the
|
||||||
|
/// caller should — throttled — request a keyframe (a drop-count climb armed a fresh freeze, or the
|
||||||
|
/// freeze is overdue and re-asks while it keeps holding).
|
||||||
|
func poll(framesDropped: UInt64) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var requestKf = false
|
||||||
|
_ = punktfunk_reanchor_gate_poll(ptr, framesDropped, &requestKf)
|
||||||
|
return requestKf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||||
|
var isHolding: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var holding = false
|
||||||
|
_ = punktfunk_reanchor_gate_is_holding(ptr, &holding)
|
||||||
|
return holding
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Resize-in-progress indicator state (design/midstream-resolution-resize.md — client UX).
|
||||||
|
//
|
||||||
|
// A mid-stream resize takes the host 0.3–2 s to rebuild its virtual display + encoder, and the
|
||||||
|
// first new-mode frame is an IDR that the decoder re-inits on. Rather than let the stream scale
|
||||||
|
// (stretch/blur) to the changing window during that gap, the client EMBRACES the delay: it shows a
|
||||||
|
// deliberate blur + spinner the instant a resize starts and clears it the instant the sharp
|
||||||
|
// new-resolution frame is on screen — so the wait reads as intentional, not as lag.
|
||||||
|
//
|
||||||
|
// This is driven ENTIRELY by signals the client already has (no new protocol):
|
||||||
|
// * START — the Match-window follower reports the size it is steering toward (instant, on the
|
||||||
|
// first resize layout, before the debounced request even leaves).
|
||||||
|
// * END — the decode pipeline reports each new-mode IDR's dimensions; when they reach the target
|
||||||
|
// the new picture is here.
|
||||||
|
// * TIMEOUT — the safety net for a switch that never delivers the exact target: the host rejected
|
||||||
|
// it (gamescope), capped it to an advertised mode, or a corrective ack landed a different size.
|
||||||
|
//
|
||||||
|
// Pure + side-effect-free so the transition logic is unit-tested without a live session or UI
|
||||||
|
// (`ResizeIndicatorTests`); `SessionModel` owns an instance and mirrors `active` into a @Published.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The pure state of the resize overlay. `now` is a monotonic time in seconds (the caller passes
|
||||||
|
/// `ProcessInfo.processInfo.systemUptime` or a test clock).
|
||||||
|
public struct ResizeIndicator {
|
||||||
|
/// Whether the blur + spinner should be shown.
|
||||||
|
public private(set) var active = false
|
||||||
|
/// The size the follower is steering toward — cleared once a decoded frame reaches it.
|
||||||
|
private var target: (width: UInt32, height: UInt32)?
|
||||||
|
/// When the current `active` span began — the timeout is measured from here.
|
||||||
|
private var since: TimeInterval?
|
||||||
|
/// How long to keep the overlay up if the target frame never arrives (rejected / capped switch).
|
||||||
|
public var timeout: TimeInterval
|
||||||
|
|
||||||
|
public init(timeout: TimeInterval = 2.5) { self.timeout = timeout }
|
||||||
|
|
||||||
|
/// The follower is steering toward `width`×`height` — a resize is under way. Show the overlay now
|
||||||
|
/// (instant feedback). Called only for a genuine change (the follower skips a target equal to the
|
||||||
|
/// live mode), possibly many times as a drag moves through sizes; the timeout re-arms whenever the
|
||||||
|
/// target actually changes so a slow drag never trips it mid-gesture.
|
||||||
|
public mutating func steering(width: UInt32, height: UInt32, now: TimeInterval) {
|
||||||
|
if !active || target?.width != width || target?.height != height {
|
||||||
|
since = now
|
||||||
|
}
|
||||||
|
target = (width, height)
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded frame arrived at `width`×`height` (a new-mode IDR). Clears the overlay once it
|
||||||
|
/// matches the steered target — the sharp new-resolution picture is on glass.
|
||||||
|
public mutating func decoded(width: UInt32, height: UInt32) {
|
||||||
|
guard active, let t = target, t.width == width, t.height == height else { return }
|
||||||
|
active = false
|
||||||
|
since = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timeout safety net: stop showing the overlay once `timeout` has elapsed with no matching frame
|
||||||
|
/// (a rejected or host-capped switch never delivers the exact target).
|
||||||
|
public mutating func tick(now: TimeInterval) {
|
||||||
|
guard active, let s = since, now - s >= timeout else { return }
|
||||||
|
active = false
|
||||||
|
since = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,15 @@ final class SessionPresenter {
|
|||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
private var metalLayer: CAMetalLayer?
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
|
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||||
|
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||||
|
/// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the
|
||||||
|
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward —
|
||||||
|
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
|
||||||
|
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
|
||||||
|
/// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout`
|
||||||
|
/// falls back to `currentMode()`. Main-thread only.
|
||||||
|
private var contentSize: CGSize?
|
||||||
|
|
||||||
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
||||||
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
||||||
@@ -85,7 +94,8 @@ final class SessionPresenter {
|
|||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -128,12 +138,14 @@ final class SessionPresenter {
|
|||||||
link.add(to: .main, forMode: .common)
|
link.add(to: .main, forMode: .common)
|
||||||
stage2Link = link
|
stage2Link = link
|
||||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||||
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
pipeline.start(
|
||||||
|
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||||
|
onDecodedSize: onDecodedSize)
|
||||||
} else {
|
} else {
|
||||||
let pump = StreamPump()
|
let pump = StreamPump()
|
||||||
pump.start(
|
pump.start(
|
||||||
connection: connection, layer: baseLayer,
|
connection: connection, layer: baseLayer,
|
||||||
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||||
self.pump = pump
|
self.pump = pump
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,22 +193,45 @@ final class SessionPresenter {
|
|||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
||||||
let fit: CGRect = (mode.width > 0 && mode.height > 0)
|
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
|
||||||
? AVMakeRect(
|
// host that delivered a different size than requested), else the negotiated mode. The shader
|
||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
|
||||||
insideRect: bounds)
|
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
|
||||||
: bounds
|
let aspect: CGSize? = {
|
||||||
|
if let c = contentSize, c.width > 0, c.height > 0 { return c }
|
||||||
|
if mode.width > 0, mode.height > 0 {
|
||||||
|
return CGSize(width: Int(mode.width), height: Int(mode.height))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
|
||||||
|
// Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect,
|
||||||
|
// so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land
|
||||||
|
// on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite — a
|
||||||
|
// uniform "everything looks soft" blur — even when the drawable itself is pixel-exact 1:1
|
||||||
|
// (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round
|
||||||
|
// origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the
|
||||||
|
// frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there.
|
||||||
|
let scale = contentsScale > 0 ? contentsScale : 1
|
||||||
|
let snapped = CGRect(
|
||||||
|
x: (fit.origin.x * scale).rounded() / scale,
|
||||||
|
y: (fit.origin.y * scale).rounded() / scale,
|
||||||
|
width: (fit.width * scale).rounded() / scale,
|
||||||
|
height: (fit.height * scale).rounded() / scale)
|
||||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = contentsScale
|
metalLayer.contentsScale = contentsScale
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = snapped
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||||
// cross-thread) — this is what the presenter sizes its drawable to.
|
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||||
|
// the drawable's texel count equals the on-screen device-pixel count exactly (1 texel ↔ 1
|
||||||
|
// device pixel); with the frame snapped, this equals the pre-snap rounded value, so the
|
||||||
|
// decoded↔drawable 1:1 the log confirmed is preserved.
|
||||||
stage2?.setDrawableTarget(CGSize(
|
stage2?.setDrawableTarget(CGSize(
|
||||||
width: (fit.width * contentsScale).rounded(),
|
width: (snapped.width * scale).rounded(),
|
||||||
height: (fit.height * contentsScale).rounded()))
|
height: (snapped.height * scale).rounded()))
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
||||||
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
||||||
@@ -206,10 +241,20 @@ final class SessionPresenter {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
|
||||||
|
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
|
||||||
|
/// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a
|
||||||
|
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
|
||||||
|
func setContentSize(_ size: CGSize) {
|
||||||
|
guard size.width > 0, size.height > 0, size != contentSize else { return }
|
||||||
|
contentSize = size
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
func stop() {
|
func stop() {
|
||||||
|
contentSize = nil // a new session re-derives it from its first frame
|
||||||
pump?.stop()
|
pump?.stop()
|
||||||
pump = nil
|
pump = nil
|
||||||
stage2Link?.invalidate()
|
stage2Link?.invalidate()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||||
@@ -249,6 +250,28 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
|
||||||
|
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
|
||||||
|
/// `init`, before the connection exists) can capture it, then `start` binds the live connection +
|
||||||
|
/// the arming flag once known — the same "reference captured in init, configured in start" shape as
|
||||||
|
/// `recovery`/`gate`. `record` runs on VideoToolbox's callback thread; `bind` runs once on the main
|
||||||
|
/// thread before the pump feeds the first AU, so the plain fields are safe (set-once, then read).
|
||||||
|
private final class DecodeReport: @unchecked Sendable {
|
||||||
|
private weak var connection: PunktfunkConnection?
|
||||||
|
private var enabled = false
|
||||||
|
func bind(_ connection: PunktfunkConnection) {
|
||||||
|
self.connection = connection
|
||||||
|
self.enabled = connection.wantsDecodeLatency()
|
||||||
|
}
|
||||||
|
/// Report received→decoded for one frame, in µs. Both stamps are client `CLOCK_REALTIME`
|
||||||
|
/// (no skew). Skips when the controller isn't armed, so it's free to call on every decode.
|
||||||
|
func record(receivedNs: Int64, decodedNs: Int64) {
|
||||||
|
guard enabled, let c = connection else { return }
|
||||||
|
let us = (decodedNs - receivedNs) / 1000
|
||||||
|
if us > 0 { c.reportDecodeUs(UInt32(min(us, Int64(UInt32.max)))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public final class Stage2Pipeline {
|
public final class Stage2Pipeline {
|
||||||
private let ring = ReadyRing()
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
@@ -257,8 +280,16 @@ public final class Stage2Pipeline {
|
|||||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||||
private let pacing: PresentPacing
|
private let pacing: PresentPacing
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
|
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||||
|
/// binds the live connection + arming flag (see DecodeReport).
|
||||||
|
private let decodeReport = DecodeReport()
|
||||||
|
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
||||||
|
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
|
||||||
|
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||||
|
private let gate = ReanchorGate(framesDropped: 0)
|
||||||
private var token = StopFlag()
|
private var token = StopFlag()
|
||||||
private var offsetNs: Int64 = 0
|
private var offsetNs: Int64 = 0
|
||||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||||
@@ -302,25 +333,39 @@ public final class Stage2Pipeline {
|
|||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
|
let gate = gate
|
||||||
|
let decodeReport = decodeReport
|
||||||
self.decoder = VideoDecoder(
|
self.decoder = VideoDecoder(
|
||||||
onDecoded: { frame in
|
onDecoded: { frame in
|
||||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||||
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
||||||
// including ones the newest-wins ring drops before present.
|
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||||
decodeMeter?.record(
|
decodeMeter?.record(
|
||||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||||
|
// Same interval, reported to the core bitrate controller so Automatic caps at this
|
||||||
|
// device's real decode limit instead of the network link ceiling. Every decoded
|
||||||
|
// frame (not just presented ones), so a newest-wins drop can't hide the backlog.
|
||||||
|
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
|
||||||
|
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
||||||
|
// garbage VideoToolbox returns Ok for a reference-missing delta) — don't submit it,
|
||||||
|
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||||
|
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
|
||||||
|
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
|
||||||
|
guard gate.onDecoded(flags: frame.flags) else { return }
|
||||||
ring.submit(frame)
|
ring.submit(frame)
|
||||||
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
},
|
},
|
||||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): fold it into the
|
||||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
// gate's no-output streak (which arms the freeze after a short run, matching the desktop),
|
||||||
|
// and when that trips ask the host for a fresh IDR now (infinite GOP — it wouldn't
|
||||||
// otherwise come soon). Throttled in KeyframeRecovery.
|
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||||
onDecodeError: { _ in recovery.request() })
|
onDecodeError: { _ in if gate.onNoOutput() { recovery.request() } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
||||||
@@ -329,10 +374,13 @@ public final class Stage2Pipeline {
|
|||||||
public func start(
|
public func start(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
offsetNs = connection.clockOffsetNs
|
offsetNs = connection.clockOffsetNs
|
||||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||||
|
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||||
|
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||||
|
|
||||||
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||||
@@ -347,9 +395,27 @@ public final class Stage2Pipeline {
|
|||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let presenter = presenter
|
let presenter = presenter
|
||||||
let pumpStopped = pumpStopped
|
let pumpStopped = pumpStopped
|
||||||
let thread = Thread {
|
let reanchorGate = gate
|
||||||
|
// PyroWave rides a different decode half: no CMFormatDescription/VideoToolbox machinery
|
||||||
|
// (a wavelet AU has no parameter sets), no keyframe recovery or re-anchor freeze (the
|
||||||
|
// stream is all-intra and Phase 4's partial delivery WANTS lossy frames on glass as
|
||||||
|
// localized blur, not a freeze). The ready ring, render thread, pacing and meters are
|
||||||
|
// shared unchanged.
|
||||||
|
let thread: Thread
|
||||||
|
if connection.videoCodec == .pyrowave {
|
||||||
|
thread = Self.makePyroWavePump(
|
||||||
|
connection: connection, token: token, pumpStopped: pumpStopped,
|
||||||
|
ring: ring, renderSignal: renderSignal,
|
||||||
|
device: presenter.metalDevice, queue: presenter.metalQueue,
|
||||||
|
decodeMeter: decodeMeter,
|
||||||
|
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||||
|
} else {
|
||||||
|
thread = Thread {
|
||||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
|
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||||
|
// loss-recovery IDR at the same size (see StreamPump).
|
||||||
|
var lastDecodedDims: CMVideoDimensions?
|
||||||
var lastFramesDropped = connection.framesDropped()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||||
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
||||||
@@ -375,6 +441,9 @@ public final class Stage2Pipeline {
|
|||||||
awaitingIDR = true
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
if awaitingIDR { recovery.request() }
|
if awaitingIDR { recovery.request() }
|
||||||
|
// Freeze backstop: a drop-count climb arms the gate (in case the frame-index gap
|
||||||
|
// below was itself lost), and an overdue freeze re-asks for the re-anchor.
|
||||||
|
if reanchorGate.poll(framesDropped: dropped) { recovery.request() }
|
||||||
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||||
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||||
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
||||||
@@ -384,9 +453,21 @@ public final class Stage2Pipeline {
|
|||||||
presenter.setHdrMeta(meta)
|
presenter.setHdrMeta(meta)
|
||||||
}
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||||
|
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||||
|
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||||
|
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||||
|
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||||
|
// the following concealed frames are withheld until a clean re-anchor.
|
||||||
|
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
|
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||||
|
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
|
||||||
|
lastDecodedDims = dims
|
||||||
|
onDecodedSize?(Int(dims.width), Int(dims.height))
|
||||||
|
}
|
||||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||||
}
|
}
|
||||||
guard let f = format, !token.isStopped else { return true }
|
guard let f = format, !token.isStopped else { return true }
|
||||||
@@ -413,6 +494,7 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
thread.name = "punktfunk-stage2-pump"
|
thread.name = "punktfunk-stage2-pump"
|
||||||
thread.qualityOfService = .userInteractive
|
thread.qualityOfService = .userInteractive
|
||||||
pumpJoinable = true
|
pumpJoinable = true
|
||||||
@@ -471,9 +553,7 @@ public final class Stage2Pipeline {
|
|||||||
let presentAt = vsyncEnabled
|
let presentAt = vsyncEnabled
|
||||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||||
let renderStarted = CACurrentMediaTime()
|
let renderStarted = CACurrentMediaTime()
|
||||||
let rendered = presenter.render(
|
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
|
||||||
) { presentedNs in
|
|
||||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||||
if let gate {
|
if let gate {
|
||||||
@@ -492,6 +572,18 @@ public final class Stage2Pipeline {
|
|||||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||||
debugStats?.presented(atNs: presentedNs)
|
debugStats?.presented(atNs: presentedNs)
|
||||||
}
|
}
|
||||||
|
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||||
|
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||||
|
let rendered: Bool
|
||||||
|
switch frame.image {
|
||||||
|
case .video(let pixelBuffer, let isHDR):
|
||||||
|
rendered = presenter.render(
|
||||||
|
pixelBuffer, isHDR: isHDR, presentAtMediaTime: presentAt,
|
||||||
|
onPresented: onGlass)
|
||||||
|
case .planar(let planes):
|
||||||
|
rendered = presenter.renderPlanar(
|
||||||
|
planes, presentAtMediaTime: presentAt, onPresented: onGlass)
|
||||||
|
}
|
||||||
debugStats?.renderReturned(
|
debugStats?.renderReturned(
|
||||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||||
if !rendered {
|
if !rendered {
|
||||||
@@ -559,6 +651,93 @@ public final class Stage2Pipeline {
|
|||||||
renderSignal.signal() // wake the render thread so it can observe the stop and exit
|
renderSignal.signal() // wake the render thread so it can observe the stop and exit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The PyroWave pump: AUs go straight into the Metal wavelet decoder (no VideoToolbox, no
|
||||||
|
/// format descriptions), decoded planes ride the same ready ring / render thread. All-intra
|
||||||
|
/// stream, so none of the VT pump's recovery machinery applies: keyframe/RFI requests are
|
||||||
|
/// silenced host-side for this codec, and a lossy (partial-delivery) frame is MEANT to
|
||||||
|
/// present as localized blur — never a freeze. Static + capture-by-parameter for the same
|
||||||
|
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||||
|
private static func makePyroWavePump(
|
||||||
|
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||||
|
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||||
|
device: MTLDevice, queue: MTLCommandQueue,
|
||||||
|
decodeMeter: LatencyMeter?,
|
||||||
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||||
|
) -> Thread {
|
||||||
|
// The chunk-aligned parse window = the session's negotiated shard payload (Welcome);
|
||||||
|
// the 64-byte floor mirrors the Rust client's guard against a nonsense value.
|
||||||
|
let windowSize = max(64, Int(connection.shardPayload))
|
||||||
|
return Thread {
|
||||||
|
defer { pumpStopped.signal() }
|
||||||
|
// Compiles the two compute kernels on the session's first frames' thread — ~tens of
|
||||||
|
// ms, once per session. Failure = this device can't run the negotiated codec (the
|
||||||
|
// advertisement probe should have prevented this); end the session cleanly.
|
||||||
|
guard let decoder = MetalWaveletDecoder(device: device, queue: queue) else {
|
||||||
|
if !token.isStopped { onSessionEnd?() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Newest decoded frame index — a late partial (the reassembler's 30 ms fuse can
|
||||||
|
// deliver one behind a newer complete frame) must not travel back in time.
|
||||||
|
var newestIndex: UInt32?
|
||||||
|
var lastDims: (w: Int, h: Int)?
|
||||||
|
var alive = true
|
||||||
|
while alive, !token.isStopped {
|
||||||
|
alive = autoreleasepool { () -> Bool in
|
||||||
|
do {
|
||||||
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
|
onFrame?(au)
|
||||||
|
if let newest = newestIndex,
|
||||||
|
Int32(bitPattern: au.frameIndex &- newest) <= 0 {
|
||||||
|
return true // stale (or duplicate) frame — skip
|
||||||
|
}
|
||||||
|
guard !token.isStopped else { return true }
|
||||||
|
let chunkAligned =
|
||||||
|
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||||
|
let ptsNs = au.ptsNs
|
||||||
|
let receivedNs = au.receivedNs
|
||||||
|
let flags = au.flags
|
||||||
|
let submitted = decoder.decode(
|
||||||
|
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||||
|
) { planes in
|
||||||
|
// Metal completed-handler thread — stamp + enqueue, don't block
|
||||||
|
// (the exact contract of the VT output callback).
|
||||||
|
guard let planes else { return }
|
||||||
|
var ts = timespec()
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts)
|
||||||
|
let decodedNs =
|
||||||
|
Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||||
|
decodeMeter?.record(
|
||||||
|
ptsNs: UInt64(receivedNs), atNs: decodedNs, offsetNs: 0)
|
||||||
|
ring.submit(
|
||||||
|
ReadyFrame(
|
||||||
|
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||||
|
image: .planar(planes), flags: flags))
|
||||||
|
renderSignal.signal()
|
||||||
|
}
|
||||||
|
if submitted {
|
||||||
|
newestIndex = au.frameIndex
|
||||||
|
// Decoded-size changes come from the SOF dims (this is also how a
|
||||||
|
// mid-stream Reconfigure lands here) — report like the VT pump.
|
||||||
|
if let size = decoder.decodedSize,
|
||||||
|
lastDims?.w != size.width || lastDims?.h != size.height {
|
||||||
|
lastDims = (size.width, size.height)
|
||||||
|
onDecodedSize?(size.width, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dropped AU (malformed / SOF lost / too few blocks) is just skipped:
|
||||||
|
// every PyroWave frame is independently decodable, the next one heals.
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
if !token.isStopped { onSessionEnd?() }
|
||||||
|
return false // session closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
||||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||||
/// present time (when the frame is actually on glass), not the moment we drew.
|
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ final class StreamPump {
|
|||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
layer: AVSampleBufferDisplayLayer,
|
layer: AVSampleBufferDisplayLayer,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
let token = token
|
let token = token
|
||||||
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
||||||
let recovery = KeyframeRecovery()
|
let recovery = KeyframeRecovery()
|
||||||
recovery.bind(connection)
|
recovery.bind(connection)
|
||||||
|
// Post-loss freeze-until-reanchor (shared core policy via the C ABI). Stage-1 has no per-frame
|
||||||
|
// decode callback, so the gate is folded at ENQUEUE (from the AU's wire flags): a withheld
|
||||||
|
// frame is still enqueued but flagged DoNotDisplay so the layer's decoder keeps the reference
|
||||||
|
// chain fed while the last GOOD picture stays on glass — until a clean re-anchor lifts it.
|
||||||
|
let gate = ReanchorGate(framesDropped: connection.framesDropped())
|
||||||
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
|
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
|
||||||
// this point only the pump thread drives it — assert that so the @Sendable Thread closure
|
// this point only the pump thread drives it — assert that so the @Sendable Thread closure
|
||||||
// may capture it.
|
// may capture it.
|
||||||
@@ -35,6 +41,9 @@ final class StreamPump {
|
|||||||
|
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
|
// Report the coded dims to the resize overlay only when they CHANGE (a new-mode IDR),
|
||||||
|
// not on every loss-recovery IDR at the same size — so it fires once per real switch.
|
||||||
|
var lastDecodedDims: CMVideoDimensions?
|
||||||
var lastFramesDropped = connection.framesDropped()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||||
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||||
@@ -73,12 +82,26 @@ final class StreamPump {
|
|||||||
awaitingIDR = true
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
if awaitingIDR { recovery.request() }
|
if awaitingIDR { recovery.request() }
|
||||||
|
// Freeze backstop: a drop-count climb arms the gate (should the frame-index gap
|
||||||
|
// below be lost too), and an overdue freeze re-asks for the re-anchor.
|
||||||
|
if gate.poll(framesDropped: dropped) { recovery.request() }
|
||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
|
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||||
|
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||||
|
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||||
|
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||||
|
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||||
|
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||||
if let f = idrFormat {
|
if let f = idrFormat {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
|
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||||
|
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
|
||||||
|
lastDecodedDims = dims
|
||||||
|
onDecodedSize?(Int(dims.width), Int(dims.height))
|
||||||
|
}
|
||||||
if awaitingIDR {
|
if awaitingIDR {
|
||||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||||
@@ -93,6 +116,7 @@ final class StreamPump {
|
|||||||
// delta into a failed layer can't recover it.
|
// delta into a failed layer can't recover it.
|
||||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||||
layer.flush()
|
layer.flush()
|
||||||
|
gate.arm() // a wedged decoder is a loss — freeze until the re-anchor
|
||||||
if idrFormat == nil {
|
if idrFormat == nil {
|
||||||
format = nil
|
format = nil
|
||||||
awaitingIDR = true
|
awaitingIDR = true
|
||||||
@@ -103,6 +127,13 @@ final class StreamPump {
|
|||||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||||
!token.isStopped // don't enqueue a stale frame after a restart
|
!token.isStopped // don't enqueue a stale frame after a restart
|
||||||
else { return true }
|
else { return true }
|
||||||
|
// Freeze-until-reanchor: while holding, WITHHOLD this concealed post-loss frame by
|
||||||
|
// flagging it DoNotDisplay — the layer still decodes it (keeping the reference
|
||||||
|
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
|
||||||
|
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
|
||||||
|
if !gate.onDecoded(flags: au.flags) {
|
||||||
|
StreamPump.setDoNotDisplay(sample)
|
||||||
|
}
|
||||||
layer.enqueue(sample)
|
layer.enqueue(sample)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -119,6 +150,21 @@ final class StreamPump {
|
|||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flag a sample decode-but-don't-display (`kCMSampleAttachmentKey_DoNotDisplay`). Used to
|
||||||
|
/// withhold decoder-concealed post-loss frames while the re-anchor gate holds: the layer keeps
|
||||||
|
/// its reference chain fed without flipping the frozen picture. No-op if the attachments array
|
||||||
|
/// can't be materialized (then the frame just displays — the freeze degrades to the old behavior).
|
||||||
|
private static func setDoNotDisplay(_ sample: CMSampleBuffer) {
|
||||||
|
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||||
|
sample, createIfNecessary: true), CFArrayGetCount(attachments) > 0
|
||||||
|
else { return }
|
||||||
|
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||||
|
CFDictionarySetValue(
|
||||||
|
dict,
|
||||||
|
Unmanaged.passUnretained(kCMSampleAttachmentKey_DoNotDisplay).toOpaque(),
|
||||||
|
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
||||||
func stop() {
|
func stop() {
|
||||||
token.stop()
|
token.stop()
|
||||||
|
|||||||
@@ -12,7 +12,23 @@ import CoreVideo
|
|||||||
import Foundation
|
import Foundation
|
||||||
import VideoToolbox
|
import VideoToolbox
|
||||||
|
|
||||||
/// One decoded frame waiting to be presented. Owns a retained `CVPixelBuffer` until shown.
|
/// A decoded frame's pixels — which present path they take. VideoToolbox codecs deliver a
|
||||||
|
/// biplanar `CVPixelBuffer` (NV12/P010/444v/x444); the PyroWave Metal decoder delivers three
|
||||||
|
/// separate R8 plane textures straight off its compute pass (there is no CVPixelBuffer — the
|
||||||
|
/// planes never leave the GPU).
|
||||||
|
public enum ReadyImage: @unchecked Sendable {
|
||||||
|
/// 8-bit NV12 / 4:4:4 biplanar (SDR) or 10-bit P010 / x444 (HDR), Metal-compatible.
|
||||||
|
/// `isHDR` = the stream is BT.2020 PQ and the presenter must configure EDR output.
|
||||||
|
case video(CVPixelBuffer, isHDR: Bool)
|
||||||
|
#if canImport(Metal)
|
||||||
|
/// PyroWave planar output (Y full-res + Cb/Cr half-res, 8-bit SDR) with its precomputed
|
||||||
|
/// CSC rows — presented by `MetalVideoPresenter.renderPlanar`.
|
||||||
|
case planar(WaveletPlanes)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded frame waiting to be presented. Owns its image (a retained `CVPixelBuffer`, or
|
||||||
|
/// the PyroWave ring textures) until shown.
|
||||||
public struct ReadyFrame: @unchecked Sendable {
|
public struct ReadyFrame: @unchecked Sendable {
|
||||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||||
public let ptsNs: UInt64
|
public let ptsNs: UInt64
|
||||||
@@ -22,24 +38,56 @@ public struct ReadyFrame: @unchecked Sendable {
|
|||||||
public let receivedNs: Int64
|
public let receivedNs: Int64
|
||||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||||
public let decodedNs: Int64
|
public let decodedNs: Int64
|
||||||
/// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
|
/// The decoded image and which present path it takes.
|
||||||
public let pixelBuffer: CVPixelBuffer
|
public let image: ReadyImage
|
||||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||||
public let isHDR: Bool
|
/// mark) at present time — the async decode callback has no other access to it. 0 when unknown.
|
||||||
|
public let flags: UInt32
|
||||||
|
|
||||||
|
/// The VideoToolbox path's buffer; nil for a PyroWave planar frame. (Kept as the accessor
|
||||||
|
/// the decode round-trip tests assert against.)
|
||||||
|
public var pixelBuffer: CVPixelBuffer? {
|
||||||
|
if case .video(let buffer, _) = image { return buffer }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this frame presents on the HDR path. PyroWave planar frames are 8-bit SDR by
|
||||||
|
/// contract.
|
||||||
|
public var isHDR: Bool {
|
||||||
|
if case .video(_, let hdr) = image { return hdr }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for
|
||||||
|
/// the decode-stage meter) and its wire `user_flags` (for the re-anchor gate). Retained across the
|
||||||
|
/// async decode and reclaimed exactly once — by the output callback for every frame VideoToolbox
|
||||||
|
/// accepts, or by `decode`'s error branch for a frame `DecodeFrame` rejected outright (the callback
|
||||||
|
/// then never fires). A tiny per-frame allocation, the price of smuggling two values (a 64-bit
|
||||||
|
/// instant plus the flags) through the single `void*` a bit-pattern scalar can't hold.
|
||||||
|
private final class FrameContext {
|
||||||
|
let receivedNs: Int64
|
||||||
|
let flags: UInt32
|
||||||
|
init(receivedNs: Int64, flags: UInt32) {
|
||||||
|
self.receivedNs = receivedNs
|
||||||
|
self.flags = flags
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
||||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
|
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon is the
|
||||||
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
|
/// retained `FrameContext` set at submit; reclaim it here (balancing `passRetained`) and unpack the
|
||||||
/// dereferenced) so the decode stage can be computed against decode-completion.
|
/// AU's receipt instant (for the decode stage) and wire flags (for the re-anchor gate).
|
||||||
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
||||||
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
||||||
guard let refcon else { return }
|
guard let refcon else { return }
|
||||||
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
|
let ctx = frameRefcon.map { Unmanaged<FrameContext>.fromOpaque($0).takeRetainedValue() }
|
||||||
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
||||||
.takeUnretainedValue()
|
.takeUnretainedValue()
|
||||||
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
|
.handleDecoded(
|
||||||
|
status: status, imageBuffer: imageBuffer, pts: pts,
|
||||||
|
receivedNs: ctx?.receivedNs ?? 0, flags: ctx?.flags ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
||||||
@@ -117,16 +165,21 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
||||||
else { lock.unlock(); return false }
|
else { lock.unlock(); return false }
|
||||||
var infoOut = VTDecodeInfoFlags()
|
var infoOut = VTDecodeInfoFlags()
|
||||||
|
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||||
|
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||||
|
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||||
|
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session,
|
session,
|
||||||
sampleBuffer: sample,
|
sampleBuffer: sample,
|
||||||
flags: [._EnableAsynchronousDecompression],
|
flags: [._EnableAsynchronousDecompression],
|
||||||
// The AU's receipt instant rides through as a bit pattern (nil for 0 — the output
|
frameRefcon: refcon,
|
||||||
// callback maps that back to 0); the callback needs it to stamp the decode stage.
|
|
||||||
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
|
|
||||||
infoFlagsOut: &infoOut)
|
infoFlagsOut: &infoOut)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
|
// DecodeFrame rejected the frame outright — the output callback will NOT fire, so
|
||||||
|
// reclaim the context here (balancing passRetained) to avoid leaking it.
|
||||||
|
Unmanaged<FrameContext>.fromOpaque(refcon).release()
|
||||||
onDecodeError(status)
|
onDecodeError(status)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -231,9 +284,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
||||||
/// AU's receipt instant threaded through the frame refcon (0 = unknown).
|
/// AU's receipt instant and `flags` its wire `user_flags`, both threaded through the frame refcon
|
||||||
|
/// (0 = unknown).
|
||||||
fileprivate func handleDecoded(
|
fileprivate func handleDecoded(
|
||||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
|
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64, flags: UInt32
|
||||||
) {
|
) {
|
||||||
guard status == noErr, let imageBuffer else {
|
guard status == noErr, let imageBuffer else {
|
||||||
onDecodeError(status)
|
onDecodeError(status)
|
||||||
@@ -259,6 +313,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
onDecoded(
|
onDecoded(
|
||||||
ReadyFrame(
|
ReadyFrame(
|
||||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||||
pixelBuffer: imageBuffer, isHDR: isHDR))
|
image: .video(imageBuffer, isHDR: isHDR), flags: flags))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let onDisconnectRequest: (() -> Void)?
|
private let onDisconnectRequest: (() -> Void)?
|
||||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
private let onSessionEnd: (@Sendable () -> Void)?
|
private let onSessionEnd: (@Sendable () -> Void)?
|
||||||
|
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||||
|
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
@@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
onDisconnectRequest: (() -> Void)? = nil,
|
onDisconnectRequest: (() -> Void)? = nil,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||||
|
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil
|
||||||
@@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.onDisconnectRequest = onDisconnectRequest
|
self.onDisconnectRequest = onDisconnectRequest
|
||||||
self.onFrame = onFrame
|
self.onFrame = onFrame
|
||||||
self.onSessionEnd = onSessionEnd
|
self.onSessionEnd = onSessionEnd
|
||||||
|
self.onResizeTarget = onResizeTarget
|
||||||
|
self.onDecodedSize = onDecodedSize
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
@@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.onResizeTarget = onResizeTarget
|
||||||
|
view.onDecodedSize = onDecodedSize
|
||||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
@@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.onResizeTarget = onResizeTarget
|
||||||
|
view.onDecodedSize = onDecodedSize
|
||||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||||
// connection identity actually changed.
|
// connection identity actually changed.
|
||||||
if view.connection !== connection {
|
if view.connection !== connection {
|
||||||
@@ -165,6 +175,15 @@ public final class StreamLayerView: NSView {
|
|||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
public private(set) var connection: PunktfunkConnection?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
|
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||||
|
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
|
||||||
|
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
|
||||||
|
private var matchFollower: MatchWindowFollower?
|
||||||
|
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
|
||||||
|
/// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a
|
||||||
|
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
|
||||||
|
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
|
||||||
|
private var lastDecodedContentSize: CGSize?
|
||||||
private let cursorCapture = CursorCapture()
|
private let cursorCapture = CursorCapture()
|
||||||
private var inputCapture: InputCapture?
|
private var inputCapture: InputCapture?
|
||||||
private var appObservers: [NSObjectProtocol] = []
|
private var appObservers: [NSObjectProtocol] = []
|
||||||
@@ -201,6 +220,13 @@ public final class StreamLayerView: NSView {
|
|||||||
/// view can't do that itself (the connection's owner disconnects).
|
/// view can't do that itself (the connection's owner disconnects).
|
||||||
public var onDisconnectRequest: (() -> Void)?
|
public var onDisconnectRequest: (() -> Void)?
|
||||||
|
|
||||||
|
/// Resize overlay signals (design/midstream-resolution-resize.md client UX): `onResizeTarget`
|
||||||
|
/// (main thread, via the follower) fires the instant the window starts steering toward a new
|
||||||
|
/// size; `onDecodedSize` (PUMP thread) fires when a new-mode IDR's dims land. The owner drives
|
||||||
|
/// the blur+spinner from these — set before `start()`.
|
||||||
|
public var onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||||
|
public var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||||
|
|
||||||
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
||||||
/// stream); flipping to true auto-engages once.
|
/// stream); flipping to true auto-engages once.
|
||||||
public var captureEnabled = true {
|
public var captureEnabled = true {
|
||||||
@@ -618,6 +644,10 @@ public final class StreamLayerView: NSView {
|
|||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
||||||
// NSView.displayLink so it tracks the display this view is on.
|
// NSView.displayLink so it tracks the display this view is on.
|
||||||
|
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||||
|
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||||
|
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||||
|
let overlayDecodedSize = onDecodedSize
|
||||||
presenter.start(
|
presenter.start(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
baseLayer: displayLayer,
|
baseLayer: displayLayer,
|
||||||
@@ -626,15 +656,39 @@ public final class StreamLayerView: NSView {
|
|||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd)
|
onSessionEnd: onSessionEnd,
|
||||||
|
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
|
||||||
|
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||||
|
overlayDecodedSize?(w, h)
|
||||||
|
})
|
||||||
|
// Match-window (C3): when ON, follow the window's pixel size so a windowed session streams
|
||||||
|
// 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
|
||||||
|
// non-matching window. The first real `layout()` feeds the initial size, so the stream
|
||||||
|
// converges to the window even though the connect used the explicit/display mode; entering
|
||||||
|
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
|
||||||
|
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||||
|
// default keeps the explicit mode.
|
||||||
|
let follower = MatchWindowFollower(
|
||||||
|
connection: connection,
|
||||||
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||||
|
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||||
|
matchFollower = follower
|
||||||
layoutPresenter()
|
layoutPresenter()
|
||||||
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
|
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
|
||||||
/// retina↔non-retina move (see SessionPresenter.layout).
|
/// retina↔non-retina move (see SessionPresenter.layout). Also feeds the Match-window follower
|
||||||
|
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||||
private func layoutPresenter() {
|
private func layoutPresenter() {
|
||||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||||
|
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||||
|
// bounds — a pre-window layout would report point-sized dimensions.
|
||||||
|
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||||
|
let px = convertToBacking(bounds).size
|
||||||
|
matchFollower?.noteSize(
|
||||||
|
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidChangeBackingProperties() {
|
public override func viewDidChangeBackingProperties() {
|
||||||
@@ -642,6 +696,18 @@ public final class StreamLayerView: NSView {
|
|||||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
|
||||||
|
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
|
||||||
|
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||||
|
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||||
|
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||||
|
let size = CGSize(width: width, height: height)
|
||||||
|
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||||
|
lastDecodedContentSize = size
|
||||||
|
presenter.setContentSize(size)
|
||||||
|
layoutPresenter()
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||||
public func stop() {
|
public func stop() {
|
||||||
@@ -650,6 +716,8 @@ public final class StreamLayerView: NSView {
|
|||||||
inputCapture?.stop()
|
inputCapture?.stop()
|
||||||
inputCapture = nil
|
inputCapture = nil
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
|
matchFollower = nil
|
||||||
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
||||||
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
||||||
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
// starts, ⌘⎋ toggles and ⌃⌥⇧Q releases (both detected from the HID stream; there is no NSEvent
|
||||||
|
// monitor here). ⌃⌥⇧Q is the cross-client Ctrl+Alt+Shift+Q — it un-captures so the Magic Keyboard
|
||||||
|
// trackpad drives the local iPad UI again.
|
||||||
//
|
//
|
||||||
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||||
// the SwiftUI app layer is identical on both platforms.
|
// the SwiftUI app layer is identical on both platforms.
|
||||||
@@ -53,6 +55,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let onCaptureChange: ((Bool) -> Void)?
|
private let onCaptureChange: ((Bool) -> Void)?
|
||||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
private let onSessionEnd: (@Sendable () -> Void)?
|
private let onSessionEnd: (@Sendable () -> Void)?
|
||||||
|
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||||
|
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
@@ -68,6 +72,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
onDisconnectRequest: (() -> Void)? = nil,
|
onDisconnectRequest: (() -> Void)? = nil,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||||
|
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil
|
||||||
@@ -77,6 +83,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
self.onCaptureChange = onCaptureChange
|
self.onCaptureChange = onCaptureChange
|
||||||
self.onFrame = onFrame
|
self.onFrame = onFrame
|
||||||
self.onSessionEnd = onSessionEnd
|
self.onSessionEnd = onSessionEnd
|
||||||
|
self.onResizeTarget = onResizeTarget
|
||||||
|
self.onDecodedSize = onDecodedSize
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
@@ -89,6 +97,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.onResizeTarget = onResizeTarget
|
||||||
|
controller.onDecodedSize = onDecodedSize
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -99,6 +109,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.onResizeTarget = onResizeTarget
|
||||||
|
controller.onDecodedSize = onDecodedSize
|
||||||
if controller.connection !== connection {
|
if controller.connection !== connection {
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
}
|
}
|
||||||
@@ -147,6 +159,12 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
/// Capture state at the last resign, restored on the next foreground — otherwise the
|
/// Capture state at the last resign, restored on the next foreground — otherwise the
|
||||||
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||||
private var wasCapturedOnResign = false
|
private var wasCapturedOnResign = false
|
||||||
|
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||||
|
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
|
||||||
|
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
|
||||||
|
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||||
|
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||||
|
private var matchFollower: MatchWindowFollower?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||||
@@ -161,6 +179,18 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var onCaptureChange: ((Bool) -> Void)?
|
var onCaptureChange: ((Bool) -> Void)?
|
||||||
|
/// Resize-overlay START: forwarded to the Match-window follower so a scene resize drives the
|
||||||
|
/// blur+spinner the instant the window differs from the live mode (iOS only — tvOS has no
|
||||||
|
/// follower). See `MatchWindowFollower.onResizeTarget`.
|
||||||
|
var onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||||
|
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
||||||
|
/// overlay clears when a frame at the requested size actually decodes.
|
||||||
|
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||||
|
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
|
||||||
|
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
|
||||||
|
/// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
|
||||||
|
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
|
||||||
|
private var lastDecodedContentSize: CGSize?
|
||||||
|
|
||||||
var captureEnabled = true {
|
var captureEnabled = true {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -309,7 +339,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||||
}
|
}
|
||||||
streamView.onPointerButton = { [weak self] button, down in
|
streamView.onPointerButton = { [weak self] button, down in
|
||||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
guard let self else { return }
|
||||||
|
// Released → a trackpad/mouse click into the video RE-ENGAGES capture (the iPad
|
||||||
|
// analogue of macOS's `mouseDown → engageCapture(fromClick:)`, and the click-mirror of
|
||||||
|
// the ⌘⎋ / ⌃⌥⇧Q keyboard toggles). Only the button-DOWN engages; that click is the local
|
||||||
|
// engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded —
|
||||||
|
// its release is swallowed by InputCapture's suppress latch, whichever path delivers it.
|
||||||
|
// (Finger taps are untouched: touch always plays directly, so only the indirect pointer
|
||||||
|
// re-captures.) Captured already → the absolute path forwards the button as before.
|
||||||
|
if !self.captured {
|
||||||
|
if down, self.captureEnabled { self.setCaptured(true, fromClick: true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||||
}
|
}
|
||||||
streamView.onScroll = { [weak self] dx, dy in
|
streamView.onScroll = { [weak self] dx, dy in
|
||||||
@@ -322,16 +364,38 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.setCaptured(!self.captured)
|
self.setCaptured(!self.captured)
|
||||||
}
|
}
|
||||||
|
// ⌃⌥⇧Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer +
|
||||||
|
// keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected
|
||||||
|
// from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the ⌘⎋ toggle it
|
||||||
|
// only ever RELEASES — re-pressing it while already released is a no-op (setCaptured guards).
|
||||||
|
capture.onReleaseCapture = { [weak self] in
|
||||||
|
self?.setCaptured(false)
|
||||||
|
}
|
||||||
capture.onPreempted = { [weak self] in
|
capture.onPreempted = { [weak self] in
|
||||||
self?.setCaptured(false)
|
self?.setCaptured(false)
|
||||||
}
|
}
|
||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
// Match-window (C3): when ON, follow the scene's pixel size so a resizable iPad scene
|
||||||
|
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
|
||||||
|
// `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation.
|
||||||
|
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
|
||||||
|
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||||
|
// default keeps the explicit mode.
|
||||||
|
let follower = MatchWindowFollower(
|
||||||
|
connection: connection,
|
||||||
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||||
|
follower.onResizeTarget = onResizeTarget
|
||||||
|
matchFollower = follower
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
||||||
|
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||||
|
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||||
|
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||||
|
let overlayDecodedSize = onDecodedSize
|
||||||
presenter.start(
|
presenter.start(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
baseLayer: streamView.displayLayer,
|
baseLayer: streamView.displayLayer,
|
||||||
@@ -340,7 +404,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd)
|
onSessionEnd: onSessionEnd,
|
||||||
|
onDecodedSize: { [weak self] w, h in
|
||||||
|
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||||
|
overlayDecodedSize?(w, h)
|
||||||
|
})
|
||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -376,6 +444,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.syncPointerLock()
|
self?.syncPointerLock()
|
||||||
})
|
})
|
||||||
|
// The Stream menu's "Release Mouse" (⌃⌥⇧Q) posts this — the discoverable menu surface for
|
||||||
|
// the RELEASED state. While CAPTURED the combo is recognized from the HID stream in
|
||||||
|
// InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a
|
||||||
|
// not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the
|
||||||
|
// foreground-active scene's stream acts — the iPad analogue of macOS's key-window guard, so a
|
||||||
|
// second Stage Manager scene isn't released out from under the user.
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .punktfunkReleaseCapture, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self,
|
||||||
|
self.view.window?.windowScene?.activationState == .foregroundActive else { return }
|
||||||
|
self.setCaptured(false)
|
||||||
|
})
|
||||||
|
|
||||||
if captureEnabled {
|
if captureEnabled {
|
||||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||||
@@ -411,6 +492,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
streamView.onPointerButton = nil
|
streamView.onPointerButton = nil
|
||||||
streamView.onScroll = nil
|
streamView.onScroll = nil
|
||||||
streamView.currentHostMode = nil
|
streamView.currentHostMode = nil
|
||||||
|
matchFollower = nil
|
||||||
#endif
|
#endif
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Return the TV to the user's preferred mode — the home screen must not stay in the
|
// Return the TV to the user's preferred mode — the home screen must not stay in the
|
||||||
@@ -419,12 +501,23 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
sessionDisplayManager = nil
|
sessionDisplayManager = nil
|
||||||
#endif
|
#endif
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidLayoutSubviews() {
|
public override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
|
#if os(iOS)
|
||||||
|
// Match-window (C3): feed the follower the view's physical-pixel size (points × scale).
|
||||||
|
let b = streamView.bounds
|
||||||
|
if b.width > 0, b.height > 0 {
|
||||||
|
let scale = renderScale
|
||||||
|
matchFollower?.noteSize(
|
||||||
|
widthPx: Int((b.width * scale).rounded()),
|
||||||
|
heightPx: Int((b.height * scale).rounded()))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
applyDisplayCriteriaIfNeeded()
|
applyDisplayCriteriaIfNeeded()
|
||||||
#endif
|
#endif
|
||||||
@@ -485,12 +578,28 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
|
||||||
|
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
|
||||||
|
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||||
|
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||||
|
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||||
|
let size = CGSize(width: width, height: height)
|
||||||
|
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||||
|
lastDecodedContentSize = size
|
||||||
|
presenter.setContentSize(size)
|
||||||
|
layoutMetalLayer()
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private func setCaptured(_ on: Bool) {
|
/// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures):
|
||||||
|
/// that click's press/release are suppressed toward the host — it's the local engage gesture,
|
||||||
|
/// not a host click — exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven
|
||||||
|
/// engages (⌘⎋) pass false so a normal click still reaches the host.
|
||||||
|
private func setCaptured(_ on: Bool, fromClick: Bool = false) {
|
||||||
if on {
|
if on {
|
||||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||||
guard captureEnabled, !captured, connection != nil else { return }
|
guard captureEnabled, !captured, connection != nil else { return }
|
||||||
inputCapture?.setForwarding(true)
|
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||||
captured = true
|
captured = true
|
||||||
} else {
|
} else {
|
||||||
guard captured else { return }
|
guard captured else { return }
|
||||||
@@ -589,6 +698,7 @@ final class StreamLayerUIView: UIView {
|
|||||||
let mouse = TouchMouse()
|
let mouse = TouchMouse()
|
||||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||||
|
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
||||||
return mouse
|
return mouse
|
||||||
}()
|
}()
|
||||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||||
@@ -599,6 +709,22 @@ final class StreamLayerUIView: UIView {
|
|||||||
func resetTouchInput() {
|
func resetTouchInput() {
|
||||||
touchMouse.reset()
|
touchMouse.reset()
|
||||||
fingerRoute = nil
|
fingerRoute = nil
|
||||||
|
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The soft keyboard is keyed off first-responder status: the three-finger swipe
|
||||||
|
/// (TouchMouse) summons/dismisses it here, and the UIKeyInput conformance below turns
|
||||||
|
/// what it types into wire key events. Also the reason `canBecomeFirstResponder` is true
|
||||||
|
/// on iOS (tvOS anchors the responder chain on the CONTROLLER instead — see
|
||||||
|
/// StreamViewController.viewDidAppear).
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
func setSoftKeyboardVisible(_ visible: Bool) {
|
||||||
|
if visible {
|
||||||
|
becomeFirstResponder()
|
||||||
|
} else if isFirstResponder {
|
||||||
|
resignFirstResponder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -770,4 +896,46 @@ final class StreamLayerUIView: UIView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// The soft keyboard's output → wire key events. UIKeyInput is deliberately minimal (no
|
||||||
|
// UITextInput): the stream needs keystrokes, not an editing buffer — insertions map through
|
||||||
|
// `SoftKeyMap` to US-positional VKs (with a VK_LSHIFT wrap for shifted characters) and
|
||||||
|
// characters outside the map (emoji, non-Latin scripts) are dropped, matching the wire's VK
|
||||||
|
// contract. Events ride the same `onTouchEvent` path as the touch-driven mouse, so they're
|
||||||
|
// gated on captureEnabled with everything else and can't leak past a trust prompt.
|
||||||
|
extension StreamLayerUIView: UIKeyInput {
|
||||||
|
// Keep the IME literal — no autocorrect/smart substitutions; a remote desktop is not prose,
|
||||||
|
// and the host does its own text handling.
|
||||||
|
var autocorrectionType: UITextAutocorrectionType { get { .no } set {} }
|
||||||
|
var autocapitalizationType: UITextAutocapitalizationType { get { .none } set {} }
|
||||||
|
var spellCheckingType: UITextSpellCheckingType { get { .no } set {} }
|
||||||
|
var smartQuotesType: UITextSmartQuotesType { get { .no } set {} }
|
||||||
|
var smartDashesType: UITextSmartDashesType { get { .no } set {} }
|
||||||
|
var smartInsertDeleteType: UITextSmartInsertDeleteType { get { .no } set {} }
|
||||||
|
var keyboardType: UIKeyboardType { get { .asciiCapable } set {} }
|
||||||
|
|
||||||
|
var hasText: Bool { false }
|
||||||
|
|
||||||
|
func insertText(_ text: String) {
|
||||||
|
// A hardware keyboard's presses reach the host through GCKeyboard AND arrive here as
|
||||||
|
// UIKeyInput insertions while we're first responder — forwarding both would double
|
||||||
|
// every character, so the HID path owns keys whenever a hardware keyboard is attached.
|
||||||
|
guard GCKeyboard.coalesced == nil else { return }
|
||||||
|
for ch in text {
|
||||||
|
guard let key = SoftKeyMap.vk(for: ch) else { continue }
|
||||||
|
if key.shift { onTouchEvent?(.key(0xA0, down: true)) } // VK_LSHIFT
|
||||||
|
onTouchEvent?(.key(key.vk, down: true))
|
||||||
|
onTouchEvent?(.key(key.vk, down: false))
|
||||||
|
if key.shift { onTouchEvent?(.key(0xA0, down: false)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBackward() {
|
||||||
|
guard GCKeyboard.coalesced == nil else { return } // see insertText
|
||||||
|
onTouchEvent?(.key(0x08, down: true)) // VK_BACK
|
||||||
|
onTouchEvent?(.key(0x08, down: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -237,10 +237,11 @@ final class AV1Tests: XCTestCase {
|
|||||||
let ready = try XCTUnwrap(frame)
|
let ready = try XCTUnwrap(frame)
|
||||||
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
||||||
XCTAssertFalse(ready.isHDR)
|
XCTAssertFalse(ready.isHDR)
|
||||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320)
|
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180)
|
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 320)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 180)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
CVPixelBufferGetPixelFormatType(ready.pixelBuffer),
|
CVPixelBufferGetPixelFormatType(buffer),
|
||||||
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// player-LED-bits → GCControllerPlayerIndex map. All pure functions.
|
// player-LED-bits → GCControllerPlayerIndex map. All pure functions.
|
||||||
|
|
||||||
import GameController
|
import GameController
|
||||||
|
import PunktfunkCore
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import PunktfunkKit
|
@testable import PunktfunkKit
|
||||||
@@ -26,11 +27,16 @@ final class GamepadWireTests: XCTestCase {
|
|||||||
XCTAssertEqual(GamepadWire.x, 0x4000)
|
XCTAssertEqual(GamepadWire.x, 0x4000)
|
||||||
XCTAssertEqual(GamepadWire.y, 0x8000)
|
XCTAssertEqual(GamepadWire.y, 0x8000)
|
||||||
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
|
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
|
||||||
|
XCTAssertEqual(GamepadWire.misc1, 0x0020_0000)
|
||||||
// Every button is enumerated exactly once (releaseAll walks this list).
|
// Every button is enumerated exactly once (releaseAll walks this list).
|
||||||
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
|
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
|
||||||
XCTAssertEqual(combined, 0x0010_F7FF)
|
XCTAssertEqual(combined, 0x0030_F7FF)
|
||||||
XCTAssertEqual(GamepadWire.allButtons.count, 16)
|
XCTAssertEqual(GamepadWire.allButtons.count, 17)
|
||||||
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
|
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
|
||||||
|
// Paddles are defined but not yet forwarded, so they stay out of allButtons for now.
|
||||||
|
for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] {
|
||||||
|
XCTAssertFalse(GamepadWire.allButtons.contains(paddle))
|
||||||
|
}
|
||||||
// Axis ids.
|
// Axis ids.
|
||||||
XCTAssertEqual(GamepadWire.axisLSX, 0)
|
XCTAssertEqual(GamepadWire.axisLSX, 0)
|
||||||
XCTAssertEqual(GamepadWire.axisLSY, 1)
|
XCTAssertEqual(GamepadWire.axisLSY, 1)
|
||||||
@@ -40,6 +46,79 @@ final class GamepadWireTests: XCTestCase {
|
|||||||
XCTAssertEqual(GamepadWire.axisRT, 5)
|
XCTAssertEqual(GamepadWire.axisRT, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testButtonBitsMatchTheCABIVerbatim() {
|
||||||
|
// Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same
|
||||||
|
// source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the
|
||||||
|
// Rust contract fails CI — not just the handful spot-checked above. (Cross-cutting review
|
||||||
|
// finding G15: the button values were re-declared per client with only a 3-of-19 check.)
|
||||||
|
XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP))
|
||||||
|
XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN))
|
||||||
|
XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT))
|
||||||
|
XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT))
|
||||||
|
XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START))
|
||||||
|
XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK))
|
||||||
|
XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK))
|
||||||
|
XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK))
|
||||||
|
XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB))
|
||||||
|
XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB))
|
||||||
|
XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE))
|
||||||
|
XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A))
|
||||||
|
XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B))
|
||||||
|
XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X))
|
||||||
|
XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y))
|
||||||
|
XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD))
|
||||||
|
XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3))
|
||||||
|
XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4))
|
||||||
|
// Axis ids and pad count share the same header.
|
||||||
|
XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X))
|
||||||
|
XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y))
|
||||||
|
XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X))
|
||||||
|
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||||
|
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||||
|
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||||
|
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||||
|
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis.
|
||||||
|
let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)
|
||||||
|
XCTAssertEqual(btn.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue))
|
||||||
|
XCTAssertEqual(btn.code, GamepadWire.a)
|
||||||
|
XCTAssertEqual(btn.x, 1)
|
||||||
|
XCTAssertEqual(btn.flags, 3)
|
||||||
|
let axis = PunktfunkInputEvent.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 5)
|
||||||
|
XCTAssertEqual(axis.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue))
|
||||||
|
XCTAssertEqual(axis.code, GamepadWire.axisRT)
|
||||||
|
XCTAssertEqual(axis.x, 200)
|
||||||
|
XCTAssertEqual(axis.flags, 5)
|
||||||
|
// Single-controller path stays byte-identical: pad 0 ⇒ flags 0, exactly as before.
|
||||||
|
XCTAssertEqual(PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: false, pad: 0).flags, 0)
|
||||||
|
XCTAssertEqual(PunktfunkInputEvent.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0).flags, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArrivalAndRemoveWireLayout() {
|
||||||
|
// GamepadArrival (kind 14): code = the GamepadType wire byte, flags = pad index.
|
||||||
|
let arrival = PunktfunkInputEvent.gamepadArrival(
|
||||||
|
pref: PunktfunkConnection.GamepadType.dualSense.rawValue, pad: 2)
|
||||||
|
XCTAssertEqual(arrival.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue))
|
||||||
|
XCTAssertEqual(arrival.code, PunktfunkConnection.GamepadType.dualSense.rawValue) // 2
|
||||||
|
XCTAssertEqual(arrival.flags, 2)
|
||||||
|
// The GamepadType raw values ARE the GamepadPref wire bytes the host resolves.
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.xbox360.rawValue, 1)
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.dualSense.rawValue, 2)
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.xboxOne.rawValue, 3)
|
||||||
|
XCTAssertEqual(PunktfunkConnection.GamepadType.dualShock4.rawValue, 4)
|
||||||
|
// GamepadRemove (kind 13): flags = pad index (the core stamps the per-pad seq).
|
||||||
|
let remove = PunktfunkInputEvent.gamepadRemove(pad: 7)
|
||||||
|
XCTAssertEqual(remove.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue))
|
||||||
|
XCTAssertEqual(remove.flags, 7)
|
||||||
|
// 16 addressable pads (punktfunk_core::input::MAX_PADS).
|
||||||
|
XCTAssertEqual(GamepadWire.maxPads, 16)
|
||||||
|
}
|
||||||
|
|
||||||
func testTouchpadConversionCorners() {
|
func testTouchpadConversionCorners() {
|
||||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// The Match-window trigger discipline (design/midstream-resolution-resize.md D2), as pure
|
||||||
|
// functions — the same rules the session binary's `resize_decision` unit-tests: physical pixels
|
||||||
|
// even-floored and clamped ≥ 320×200, skip a size equal to the live mode, and request each
|
||||||
|
// distinct size at most once (so a rejected size / a host rollback can't loop).
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class MatchWindowTests: XCTestCase {
|
||||||
|
func testNormalizeEvenFloorsAndClamps() {
|
||||||
|
// Odd pixels floor to even (the host rejects odd dimensions).
|
||||||
|
let a = MatchWindow.normalize(widthPx: 1001, heightPx: 601)
|
||||||
|
XCTAssertEqual(a.width, 1000)
|
||||||
|
XCTAssertEqual(a.height, 600)
|
||||||
|
// Already-even sizes pass through.
|
||||||
|
let b = MatchWindow.normalize(widthPx: 2560, heightPx: 1440)
|
||||||
|
XCTAssertEqual(b.width, 2560)
|
||||||
|
XCTAssertEqual(b.height, 1440)
|
||||||
|
// Tiny / zero clamp to the host floor.
|
||||||
|
let c = MatchWindow.normalize(widthPx: 100, heightPx: 80)
|
||||||
|
XCTAssertEqual(c.width, 320)
|
||||||
|
XCTAssertEqual(c.height, 200)
|
||||||
|
let z = MatchWindow.normalize(widthPx: 0, heightPx: -4)
|
||||||
|
XCTAssertEqual(z.width, 320)
|
||||||
|
XCTAssertEqual(z.height, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequestSkipsEqualAndAlreadyRequested() {
|
||||||
|
// A new size (different from the live mode, not yet requested) → request it.
|
||||||
|
let r = MatchWindow.request(
|
||||||
|
target: (1000, 600), current: (1280, 720), lastRequested: (800, 500))
|
||||||
|
XCTAssertEqual(r?.width, 1000)
|
||||||
|
XCTAssertEqual(r?.height, 600)
|
||||||
|
// Equal to the live mode → nothing to do.
|
||||||
|
XCTAssertNil(MatchWindow.request(
|
||||||
|
target: (1280, 720), current: (1280, 720), lastRequested: nil))
|
||||||
|
// Already requested once → don't re-ask (covers a rejected size AND a host rollback:
|
||||||
|
// accepted → rebuild failed → corrective ack restored the old mode must not loop).
|
||||||
|
XCTAssertNil(MatchWindow.request(
|
||||||
|
target: (1000, 600), current: (1280, 720), lastRequested: (1000, 600)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// PyroWave Metal decoder tests — two layers:
|
||||||
|
//
|
||||||
|
// 1. Bitstream/window-walk parser tests (pure CPU): hand-crafted packet streams assert the
|
||||||
|
// exact wire semantics of pyrowave_decoder.cpp's push_packet walk + the Phase-4
|
||||||
|
// chunk-aligned framing (4-byte window prefix, FRAG chains, zeroed missing shards).
|
||||||
|
//
|
||||||
|
// 2. Golden-frame PSNR tests (Metal GPU): host-encoded fixtures (crates/punktfunk-host
|
||||||
|
// encode/linux/pyrowave.rs `pyrowave_dump_golden`, run on a Vulkan box) decoded by the
|
||||||
|
// Metal port and PSNR-matched against upstream's own decoder output. Float wavelet math is
|
||||||
|
// not bit-exact across implementations (upstream ships precision variants), so the gate is
|
||||||
|
// PSNR, not equality. This is the §4.7 validation oracle for the hand-ported kernels —
|
||||||
|
// the gather/mirror addressing in idwt is the spot most likely to drift.
|
||||||
|
|
||||||
|
#if canImport(Metal)
|
||||||
|
import Metal
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class PyroWaveParserTests: XCTestCase {
|
||||||
|
// 256x144 → aligned 256x160; block space identical to the committed fixtures.
|
||||||
|
private let width = 256
|
||||||
|
private let height = 144
|
||||||
|
|
||||||
|
/// A BitstreamSequenceHeader (START_OF_FRAME) for `width`x`height`, 4:2:0 BT.709 limited.
|
||||||
|
private func sof(totalBlocks: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||||||
|
let word0 =
|
||||||
|
UInt32(width - 1) | (UInt32(height - 1) << 14) | (sequence << 28) | (1 << 31)
|
||||||
|
// code=0 (SOF), chroma=0 (420), primaries/trc/matrix=0 (BT.709), range=1 (LIMITED),
|
||||||
|
// siting=0.
|
||||||
|
let word1 = UInt32(totalBlocks) | (1 << 30)
|
||||||
|
return le32(word0) + le32(word1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal coefficient packet: ballot=0 (all 8x8 blocks empty — legal and decodable),
|
||||||
|
/// payload_words=2 (header only).
|
||||||
|
private func packet(blockIndex: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||||||
|
let word0 = UInt32(0) | (2 << 16) | (sequence << 28)
|
||||||
|
let word1 = UInt32(0) | (UInt32(blockIndex) << 8)
|
||||||
|
return le32(word0) + le32(word1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func le32(_ v: UInt32) -> [UInt8] {
|
||||||
|
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8(v >> 24)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap bodies into `windowSize`-sized windows with the 4-byte used/kind prefix.
|
||||||
|
private func window(_ body: [UInt8], kind: UInt16, size: Int) -> [UInt8] {
|
||||||
|
precondition(body.count + 4 <= size)
|
||||||
|
var out = [UInt8(body.count & 0xff), UInt8(body.count >> 8)]
|
||||||
|
out += [UInt8(kind & 0xff), UInt8(kind >> 8)]
|
||||||
|
out += body
|
||||||
|
out += [UInt8](repeating: 0, count: size - out.count)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLayoutMatchesUpstreamBlockSpace() {
|
||||||
|
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||||||
|
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||||||
|
let layout = WaveletLayout(width: width, height: height)
|
||||||
|
XCTAssertEqual(layout.alignedWidth, 256)
|
||||||
|
XCTAssertEqual(layout.alignedHeight, 160)
|
||||||
|
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||||
|
XCTAssertEqual(layout.levelHeight(0), 80)
|
||||||
|
XCTAssertEqual(layout.levelWidth(4), 8)
|
||||||
|
XCTAssertEqual(layout.levelHeight(4), 5)
|
||||||
|
// Hand-summed: L4 (8x5 → 1 block) × 3 comps × 4 bands = 12; L3 (16x10 → 1) × 9 = 9;
|
||||||
|
// L2 (32x20 → 1) × 9 = 9; L1 (64x40 → 2x2=4... ) — trust the invariant instead:
|
||||||
|
// every band's count is ceil(w8/4)*ceil(h8/4) and the total is their sum.
|
||||||
|
var expected = 0
|
||||||
|
for level in stride(from: 4, through: 0, by: -1) {
|
||||||
|
let w8 = (layout.levelWidth(level) + 7) / 8
|
||||||
|
let h8 = (layout.levelHeight(level) + 7) / 8
|
||||||
|
let per = ((w8 + 3) / 4) * ((h8 + 3) / 4)
|
||||||
|
for component in 0..<3 {
|
||||||
|
if level == 0 && component != 0 { continue }
|
||||||
|
expected += per * (level == 4 ? 4 : 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(layout.blockCount32, expected)
|
||||||
|
// The finest luma level's stride is its 32-block row width.
|
||||||
|
XCTAssertEqual(layout.blockMeta[0][0][1].stride, (128 + 31) / 32)
|
||||||
|
// Level-0 chroma is not coded in 4:2:0.
|
||||||
|
XCTAssertEqual(layout.blockMeta[1][0][1].offset, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||||
|
let layout = WaveletLayout(width: width, height: height)
|
||||||
|
var au = sof(totalBlocks: 4)
|
||||||
|
au += packet(blockIndex: 0)
|
||||||
|
au += packet(blockIndex: 3)
|
||||||
|
au += packet(blockIndex: 3) // duplicate — first wins, not double-counted
|
||||||
|
au += packet(blockIndex: layout.blockCount32 - 1)
|
||||||
|
let frame = try XCTUnwrap(
|
||||||
|
WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||||
|
XCTAssertEqual(frame.layout.width, width)
|
||||||
|
XCTAssertEqual(frame.totalBlocks, 4)
|
||||||
|
XCTAssertEqual(frame.decodedBlocks, 3)
|
||||||
|
XCTAssertEqual(frame.offsets[0], 0)
|
||||||
|
XCTAssertEqual(frame.offsets[3], 2) // u32 words: each header-only packet is 2 words
|
||||||
|
XCTAssertEqual(frame.offsets[1], UInt32.max)
|
||||||
|
XCTAssertEqual(frame.payload.count, 6)
|
||||||
|
XCTAssertFalse(frame.bt2020)
|
||||||
|
XCTAssertFalse(frame.fullRange) // range bit 1 = LIMITED
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHalfOrFewerBlocksIsDropped() {
|
||||||
|
var au = sof(totalBlocks: 4)
|
||||||
|
au += packet(blockIndex: 0)
|
||||||
|
au += packet(blockIndex: 1)
|
||||||
|
// 2 of 4 decoded = exactly half — upstream requires MORE than half.
|
||||||
|
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingSOFIsDropped() {
|
||||||
|
let au = packet(blockIndex: 0) + packet(blockIndex: 1)
|
||||||
|
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTruncatedPacketIsRejected() {
|
||||||
|
var au = sof(totalBlocks: 1)
|
||||||
|
// Claims 4 payload words but only the 8-byte header follows.
|
||||||
|
let word0 = UInt32(0) | (4 << 16) | (1 << 28)
|
||||||
|
au += le32(word0) + le32(0)
|
||||||
|
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowWalkPackedFragAndMissingShard() throws {
|
||||||
|
let size = 64
|
||||||
|
// Window 1: SOF + one packet, PACKED. Window 2: a FRAG chain carrying one packet split
|
||||||
|
// across two windows. Window 3: all zeros (a lost shard of a partial frame). Window 4:
|
||||||
|
// a PACKED packet — the chain break must not eat it.
|
||||||
|
let fragPacket = packet(blockIndex: 2)
|
||||||
|
var au = window(sof(totalBlocks: 3) + packet(blockIndex: 0), kind: 0, size: size)
|
||||||
|
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||||||
|
au += window(Array(fragPacket[5...]), kind: 3, size: size)
|
||||||
|
au += [UInt8](repeating: 0, count: size) // missing shard
|
||||||
|
au += window(packet(blockIndex: 1), kind: 0, size: size)
|
||||||
|
let frame = try XCTUnwrap(
|
||||||
|
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||||||
|
XCTAssertEqual(frame.decodedBlocks, 3)
|
||||||
|
XCTAssertEqual(frame.offsets[0], 0)
|
||||||
|
XCTAssertEqual(frame.offsets[2], 2)
|
||||||
|
XCTAssertEqual(frame.offsets[1], 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBrokenFragChainIsDiscarded() throws {
|
||||||
|
let size = 64
|
||||||
|
let fragPacket = packet(blockIndex: 2)
|
||||||
|
var au = window(sof(totalBlocks: 1) + packet(blockIndex: 0), kind: 0, size: size)
|
||||||
|
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||||||
|
au += [UInt8](repeating: 0, count: size) // the chain's middle shard was lost
|
||||||
|
au += window(Array(fragPacket[5...]), kind: 3, size: size) // dangling LAST — dropped
|
||||||
|
let frame = try XCTUnwrap(
|
||||||
|
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||||||
|
XCTAssertEqual(frame.decodedBlocks, 1)
|
||||||
|
XCTAssertEqual(frame.offsets[2], UInt32.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Golden-frame decode against the committed host-encoder fixtures. Skipped when the machine
|
||||||
|
/// has no Metal device (headless CI) — everywhere else this is the hand-ported kernels' guard.
|
||||||
|
final class PyroWaveGoldenTests: XCTestCase {
|
||||||
|
private static let fixtureDir = "PyroWaveFixtures"
|
||||||
|
|
||||||
|
private func fixture(_ name: String) throws -> Data {
|
||||||
|
let url = try XCTUnwrap(
|
||||||
|
Bundle.module.url(
|
||||||
|
forResource: name, withExtension: "bin", subdirectory: Self.fixtureDir),
|
||||||
|
"missing fixture \(name).bin — regenerate with pyrowave_dump_golden")
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completion box — the decode callback lands on a Metal thread.
|
||||||
|
private final class ResultBox: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var planes: WaveletPlanes?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `au` synchronously and read all three planes back to CPU bytes.
|
||||||
|
private func decode(
|
||||||
|
au: Data, chunkAligned: Bool, windowSize: Int
|
||||||
|
) throws -> (y: [UInt8], cb: [UInt8], cr: [UInt8]) {
|
||||||
|
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
|
||||||
|
let queue = try XCTUnwrap(device.makeCommandQueue())
|
||||||
|
let decoder = try XCTUnwrap(MetalWaveletDecoder(device: device, queue: queue))
|
||||||
|
let done = expectation(description: "decode completes")
|
||||||
|
let box = ResultBox()
|
||||||
|
let submitted = decoder.decode(
|
||||||
|
au: au, chunkAligned: chunkAligned, windowSize: windowSize
|
||||||
|
) { planes in
|
||||||
|
box.lock.lock()
|
||||||
|
box.planes = planes
|
||||||
|
box.lock.unlock()
|
||||||
|
done.fulfill()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(submitted, "the fixture AU must parse")
|
||||||
|
wait(for: [done], timeout: 10)
|
||||||
|
box.lock.lock()
|
||||||
|
let result = box.planes
|
||||||
|
box.lock.unlock()
|
||||||
|
let planes = try XCTUnwrap(result, "the GPU pass must complete without error")
|
||||||
|
return (
|
||||||
|
try readback(planes.y, device: device, queue: queue),
|
||||||
|
try readback(planes.cb, device: device, queue: queue),
|
||||||
|
try readback(planes.cr, device: device, queue: queue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readback(
|
||||||
|
_ texture: MTLTexture, device: MTLDevice, queue: MTLCommandQueue
|
||||||
|
) throws -> [UInt8] {
|
||||||
|
let bytesPerRow = texture.width
|
||||||
|
let length = bytesPerRow * texture.height
|
||||||
|
let buffer = try XCTUnwrap(device.makeBuffer(length: length, options: .storageModeShared))
|
||||||
|
let cmd = try XCTUnwrap(queue.makeCommandBuffer())
|
||||||
|
let blit = try XCTUnwrap(cmd.makeBlitCommandEncoder())
|
||||||
|
blit.copy(
|
||||||
|
from: texture, sourceSlice: 0, sourceLevel: 0,
|
||||||
|
sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
|
||||||
|
sourceSize: MTLSize(width: texture.width, height: texture.height, depth: 1),
|
||||||
|
to: buffer, destinationOffset: 0, destinationBytesPerRow: bytesPerRow,
|
||||||
|
destinationBytesPerImage: length)
|
||||||
|
blit.endEncoding()
|
||||||
|
cmd.commit()
|
||||||
|
cmd.waitUntilCompleted()
|
||||||
|
return [UInt8](UnsafeRawBufferPointer(start: buffer.contents(), count: length))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func psnr(_ a: [UInt8], _ b: [UInt8]) -> Double {
|
||||||
|
precondition(a.count == b.count)
|
||||||
|
var sse = 0.0
|
||||||
|
for i in 0..<a.count {
|
||||||
|
let d = Double(a[i]) - Double(b[i])
|
||||||
|
sse += d * d
|
||||||
|
}
|
||||||
|
if sse == 0 { return .infinity }
|
||||||
|
let mse = sse / Double(a.count)
|
||||||
|
return 10 * log10(255.0 * 255.0 / mse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertMatchesReference(
|
||||||
|
_ decoded: (y: [UInt8], cb: [UInt8], cr: [UInt8]), prefix: String,
|
||||||
|
file: StaticString = #filePath, line: UInt = #line
|
||||||
|
) throws {
|
||||||
|
for (name, plane, ref) in [
|
||||||
|
("y", decoded.y, try fixture("\(prefix)-y")),
|
||||||
|
("cb", decoded.cb, try fixture("\(prefix)-cb")),
|
||||||
|
("cr", decoded.cr, try fixture("\(prefix)-cr")),
|
||||||
|
] {
|
||||||
|
XCTAssertEqual(plane.count, ref.count, file: file, line: line)
|
||||||
|
let db = psnr(plane, [UInt8](ref))
|
||||||
|
print("pyrowave golden \(prefix) \(name): \(db) dB")
|
||||||
|
// The Metal port and upstream's decoder run the same math at the same precision
|
||||||
|
// tier; residual differences are float rounding + the gather/mirror edge handling.
|
||||||
|
// Well-matched ports measure ≫50 dB; 45 catches a real divergence long before it
|
||||||
|
// is visible.
|
||||||
|
XCTAssertGreaterThan(db, 45.0, "plane PSNR \(db) dB", file: file, line: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDenseGoldenFrame() throws {
|
||||||
|
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||||
|
let au = try fixture("au-dense")
|
||||||
|
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||||||
|
try assertMatchesReference(decoded, prefix: "ref-dense")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChunkAlignedGoldenFrame() throws {
|
||||||
|
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||||
|
let au = try fixture("au-chunked")
|
||||||
|
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||||||
|
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||||||
|
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||||
|
/// localized blur, not garbage).
|
||||||
|
func testPartialFrameStillDecodes() throws {
|
||||||
|
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||||
|
var au = try fixture("au-chunked")
|
||||||
|
let windows = au.count / 1408
|
||||||
|
try XCTSkipIf(windows < 3, "fixture too small to punch a hole in")
|
||||||
|
let hole = (windows / 2) * 1408
|
||||||
|
au.replaceSubrange(hole..<(hole + 1408), with: [UInt8](repeating: 0, count: 1408))
|
||||||
|
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||||||
|
let ref = try fixture("ref-chunked-y")
|
||||||
|
let db = psnr(decoded.y, [UInt8](ref))
|
||||||
|
XCTAssertGreaterThan(db, 25.0, "lossy frame should still resemble the source (\(db) dB)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class ResizeIndicatorTests: XCTestCase {
|
||||||
|
func testInactiveUntilSteered() {
|
||||||
|
var r = ResizeIndicator()
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
// A decoded frame with nothing pending is a no-op (session start / steady state).
|
||||||
|
r.decoded(width: 1920, height: 1080)
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSteeringActivatesAndDecodedTargetClears() {
|
||||||
|
var r = ResizeIndicator()
|
||||||
|
r.steering(width: 2560, height: 1440, now: 0)
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
// A frame at a DIFFERENT size (the old mode still draining) doesn't clear it.
|
||||||
|
r.decoded(width: 1920, height: 1080)
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
// The target frame lands → clear.
|
||||||
|
r.decoded(width: 2560, height: 1440)
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimeoutClearsWhenTargetNeverArrives() {
|
||||||
|
var r = ResizeIndicator(timeout: 2.5)
|
||||||
|
r.steering(width: 2560, height: 1440, now: 10)
|
||||||
|
r.tick(now: 12) // 2 s < timeout — still up
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
r.tick(now: 12.6) // 2.6 s ≥ timeout — a rejected/capped switch clears
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDragReArmsTimeoutOnEachNewTarget() {
|
||||||
|
var r = ResizeIndicator(timeout: 2.5)
|
||||||
|
r.steering(width: 2000, height: 1200, now: 0)
|
||||||
|
r.steering(width: 2200, height: 1200, now: 2) // target changed → since re-armed to 2
|
||||||
|
r.tick(now: 4) // only 2 s since the last change — still up (drag isn't a timeout)
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
r.tick(now: 4.6) // 2.6 s since the last change → clears
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSteadyDragDoesNotResetTimeout() {
|
||||||
|
var r = ResizeIndicator(timeout: 2.5)
|
||||||
|
r.steering(width: 2560, height: 1440, now: 0)
|
||||||
|
r.steering(width: 2560, height: 1440, now: 1) // SAME target → since stays 0
|
||||||
|
r.tick(now: 2.6) // 2.6 s since the ORIGINAL steer → clears (not reset by the repeat)
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,18 +47,21 @@ final class Stage444Tests: XCTestCase {
|
|||||||
box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock()
|
box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock()
|
||||||
XCTAssertNil(error.map { "decode error \($0)" })
|
XCTAssertNil(error.map { "decode error \($0)" })
|
||||||
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
||||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 256)
|
guard case .video(let buffer, let isHDR) = ready.image else {
|
||||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 256)
|
return XCTFail("a VideoToolbox decode must deliver a .video frame")
|
||||||
let pf = CVPixelBufferGetPixelFormatType(ready.pixelBuffer)
|
}
|
||||||
|
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 256)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 256)
|
||||||
|
let pf = CVPixelBufferGetPixelFormatType(buffer)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
||||||
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
||||||
"expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))")
|
"expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))")
|
||||||
XCTAssertFalse(ready.isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
XCTAssertFalse(isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
||||||
// The chroma plane (plane 1) must be FULL resolution for 4:4:4 (vs half for 4:2:0) — this is
|
// The chroma plane (plane 1) must be FULL resolution for 4:4:4 (vs half for 4:2:0) — this is
|
||||||
// what lets the unchanged shader sample chroma at the luma UV.
|
// what lets the unchanged shader sample chroma at the luma UV.
|
||||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(ready.pixelBuffer, 1), 256)
|
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(buffer, 1), 256)
|
||||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(ready.pixelBuffer, 1), 256)
|
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(buffer, 1), 256)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fourCCString(_ t: OSType) -> String {
|
private func fourCCString(_ t: OSType) -> String {
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
|||||||
box.lock.unlock()
|
box.lock.unlock()
|
||||||
XCTAssertNil(error.map { "decode error \($0)" })
|
XCTAssertNil(error.map { "decode error \($0)" })
|
||||||
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
|
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
|
||||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
|
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
|
XCTAssertEqual(CVPixelBufferGetWidth(buffer), width)
|
||||||
|
XCTAssertEqual(CVPixelBufferGetHeight(buffer), height)
|
||||||
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
|
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 395 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 13 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user