Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecfa71212d | |||
| 07e2836601 | |||
| 6ac7134e7c | |||
| 6d2e738070 | |||
| 09e2043ce0 | |||
| a513186424 | |||
| df6a5325d8 | |||
| 14c5e7c11c | |||
| 4cae1b8bb8 | |||
| b45323c0be | |||
| 1a7e3a6e4f | |||
| 4ffa2665ac | |||
| 2def3ef49e | |||
| 571e22bc0f | |||
| ce085b8e3b | |||
| 405b005a0d | |||
| 89a08f83af | |||
| 5748706631 | |||
| 2ae5cf98ee | |||
| e61d655b1e | |||
| 7099266594 | |||
| fd8a062e2c | |||
| 3c38a5f0e8 | |||
| 1f519d44f9 | |||
| abecb5226c | |||
| 11045a0f70 | |||
| d466e3e2b2 | |||
| 532b313b8c | |||
| d381cdf7f4 | |||
| f901bedf22 | |||
| 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 |
@@ -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"]
|
||||
@@ -38,3 +38,8 @@ CLAUDE.md
|
||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||
.flatpak-repo/
|
||||
.flatpak-build/
|
||||
|
||||
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
|
||||
/result
|
||||
/result-*
|
||||
.direnv/
|
||||
|
||||
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||
|
||||
## 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
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
Generated
+27
-15
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2756,9 +2756,10 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
@@ -2766,6 +2767,7 @@ dependencies = [
|
||||
"pf-ffvk",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
@@ -2778,7 +2780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,7 +2801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2808,7 +2810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2992,7 +2994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3008,7 +3010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3024,7 +3026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3039,7 +3041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3058,7 +3060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3089,7 +3091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3117,9 +3119,11 @@ dependencies = [
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"parking_lot",
|
||||
"pf-driver-proto",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
@@ -3161,7 +3165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3175,21 +3179,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pyrowave-sys",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
@@ -35,7 +36,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.1"
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
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",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.9.1"
|
||||
"version": "0.11.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2043,11 +2043,12 @@
|
||||
},
|
||||
"ApiCodec": {
|
||||
"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": [
|
||||
"h264",
|
||||
"h265",
|
||||
"av1"
|
||||
"hevc",
|
||||
"av1",
|
||||
"pyrowave"
|
||||
]
|
||||
},
|
||||
"ApiDisplayInfo": {
|
||||
@@ -2811,6 +2812,7 @@
|
||||
"app_version",
|
||||
"gfe_version",
|
||||
"codecs",
|
||||
"gamestream",
|
||||
"ports"
|
||||
],
|
||||
"properties": {
|
||||
@@ -2831,6 +2833,10 @@
|
||||
},
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "GFE version advertised to Moonlight clients."
|
||||
@@ -3393,9 +3399,16 @@
|
||||
"video_streaming",
|
||||
"audio_streaming",
|
||||
"pin_pending",
|
||||
"paired_clients"
|
||||
"paired_clients",
|
||||
"active_sessions"
|
||||
],
|
||||
"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": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
@@ -3417,7 +3430,7 @@
|
||||
},
|
||||
{
|
||||
"$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",
|
||||
"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",
|
||||
"sample_count",
|
||||
"started_unix_ms",
|
||||
"elapsed_ms",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
@@ -3606,6 +3620,12 @@
|
||||
"type": "boolean",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||
|
||||
@@ -27,12 +27,27 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Gamepad rumble feedback. -->
|
||||
<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
|
||||
devices without a gamepad. -->
|
||||
<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.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.
|
||||
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: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
|
||||
android:name=".MainActivity"
|
||||
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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -303,7 +303,8 @@ internal fun PairPinDialog(
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp) // verified host fp — caller saves + connects
|
||||
} 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)."
|
||||
}
|
||||
}
|
||||
@@ -305,13 +305,17 @@ fun ConnectScreen(
|
||||
onConnected(handle)
|
||||
} else {
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
|
||||
// `waker.waking` here land in one recompose, so the overlay slides
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
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()
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,7 +420,12 @@ fun ConnectScreen(
|
||||
}
|
||||
onConnected(handle)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -147,8 +149,38 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
) {
|
||||
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") {
|
||||
if (pads.isEmpty()) {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||
@@ -214,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. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
@@ -387,6 +492,10 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,12 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp)
|
||||
} else {
|
||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
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 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) }
|
||||
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) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// 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]. */
|
||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [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(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
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.",
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { 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(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"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.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean {
|
||||
@Composable
|
||||
fun rememberControllerConnected(): State<Boolean> {
|
||||
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) }
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
@@ -59,5 +64,7 @@ fun rememberControllerConnected(): State<Boolean> {
|
||||
connected.value = Gamepad.firstPad() != null
|
||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||
}
|
||||
return connected
|
||||
return remember {
|
||||
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -20,6 +28,9 @@ import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
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() {
|
||||
/**
|
||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||
@@ -73,6 +84,30 @@ class MainActivity : ComponentActivity() {
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lastPadIsGamepad = !isTvDevice(this)
|
||||
@@ -90,13 +125,166 @@ class MainActivity : ComponentActivity() {
|
||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||
// 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 {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]. */
|
||||
private fun resolveHighRefreshMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -129,9 +317,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||
// accidental brush of the four buttons no longer quits instantly.
|
||||
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
||||
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
||||
// can still leave — but an accidental brush of the four buttons no longer quits.
|
||||
gamepadRouter?.onButton(event, bit)
|
||||
return true // consumed
|
||||
}
|
||||
@@ -153,7 +341,18 @@ class MainActivity : ComponentActivity() {
|
||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||
val vk = Keymap.toVk(event)
|
||||
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)
|
||||
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
||||
return true // consumed — don't let the system also act on it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,23 @@ data class Settings(
|
||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||
*/
|
||||
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. */
|
||||
@@ -142,6 +161,8 @@ class SettingsStore(context: Context) {
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, 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) {
|
||||
@@ -162,6 +183,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -197,6 +220,8 @@ class SettingsStore(context: Context) {
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
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. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
@@ -226,11 +251,25 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||
val caps = display.hdrCapabilities ?: return false
|
||||
return caps.supportedHdrTypes.any {
|
||||
val types = buildSet {
|
||||
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
|
||||
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
|
||||
// (Pixel-class panels included), which would make a genuinely HDR display advertise
|
||||
// no-HDR and pin the whole session to 8-bit SDR.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
display.mode.supportedHdrTypes.forEach { add(it) }
|
||||
}
|
||||
// Union the legacy query defensively — the supported one on minSdk 31, and some vendors
|
||||
// populate only this on newer APIs.
|
||||
@Suppress("DEPRECATION")
|
||||
display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) }
|
||||
}
|
||||
// HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it.
|
||||
val supported = types.any {
|
||||
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||
}
|
||||
Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported")
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
|
||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
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
|
||||
@@ -414,6 +415,26 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ internal fun StatsOverlay(
|
||||
s: DoubleArray,
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
codecLabel: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
@@ -66,7 +67,7 @@ internal fun StatsOverlay(
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
if (detailed) {
|
||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
if (latValid) {
|
||||
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
||||
@@ -151,14 +152,15 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
|
||||
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||
* Android decoder is always HEVC (`video/hevc`).
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
|
||||
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
|
||||
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
|
||||
*/
|
||||
private fun videoFeedLine(s: DoubleArray): String? {
|
||||
private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
|
||||
if (s.size < 14) return null
|
||||
val bitDepth = s[10].toInt()
|
||||
val primaries = s[11].toInt()
|
||||
@@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? {
|
||||
2 -> "4:2:2"
|
||||
else -> "4:2:0"
|
||||
}
|
||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
val codec = codecLabel.ifEmpty { "HEVC" }
|
||||
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
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 androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -24,9 +39,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -34,7 +51,9 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
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.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -65,6 +84,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
@@ -80,6 +100,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
// Codec is resolved at the handshake (Welcome) — fixed for the session, so read its
|
||||
// label once up front (before the first snapshot renders the video-feed line).
|
||||
if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle)
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
@@ -149,6 +172,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}.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) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
@@ -166,6 +193,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
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
|
||||
// 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
|
||||
@@ -185,22 +218,88 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// the same way the Back gesture does.
|
||||
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
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed.
|
||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||
// 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 {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.onHidRaw = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
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?.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
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -221,6 +320,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).
|
||||
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()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -268,11 +370,25 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, 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
|
||||
// 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(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
@@ -281,9 +397,63 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
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 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 →
|
||||
// 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
|
||||
@@ -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;
|
||||
* 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
|
||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onCycleStats: () -> Unit,
|
||||
onKeyboard: (show: Boolean) -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
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 prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
break
|
||||
}
|
||||
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) {
|
||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||
if (pressed.size == 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.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
|
||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
prevCx = cx
|
||||
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) {
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
|
||||
@@ -214,6 +214,7 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
codecLabel = "HEVC",
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ object Gamepad {
|
||||
const val BTN_X = 0x4000
|
||||
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_*`.
|
||||
const val AXIS_LS_X = 0
|
||||
const val AXIS_LS_Y = 1
|
||||
@@ -52,6 +62,10 @@ object Gamepad {
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
const val PREF_STEAMCONTROLLER = 5
|
||||
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.
|
||||
private const val VID_SONY = 0x054C
|
||||
@@ -59,10 +73,19 @@ object Gamepad {
|
||||
private const val VID_VALVE = 0x28DE
|
||||
private const val VID_NINTENDO = 0x057E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
// Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
|
||||
// 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)
|
||||
|
||||
// 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) /
|
||||
// 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
|
||||
@@ -70,6 +93,12 @@ object Gamepad {
|
||||
private val PID_STEAMDECK = setOf(0x1205)
|
||||
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
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
@@ -91,10 +120,15 @@ object Gamepad {
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
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_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.hardware.lights.Light
|
||||
import android.hardware.lights.LightState
|
||||
@@ -33,13 +34,24 @@ import java.nio.ByteBuffer
|
||||
*
|
||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||
* verification path; the `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, private val router: GamepadRouter?) {
|
||||
class GamepadFeedback(
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
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.
|
||||
// A new host renews far below this, so it never actually holds this long there.
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
@@ -101,7 +113,8 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
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) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
@@ -127,7 +140,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
// Threads are dead — drop any held rumble and close every lights session.
|
||||
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
||||
// lights session.
|
||||
runCatching { deviceVibrator?.cancel() }
|
||||
synchronized(bindsLock) {
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
@@ -203,6 +218,11 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
*/
|
||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||
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 hi = toAmplitude(high)
|
||||
@@ -246,6 +266,29 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
@@ -290,10 +333,32 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||
0b00000 -> 0
|
||||
@@ -349,3 +414,18 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() }
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
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
|
||||
@@ -84,28 +92,37 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
* 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;
|
||||
* [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.
|
||||
* 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) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) 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()
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
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()
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +132,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
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()) {
|
||||
@@ -126,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,8 +173,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/**
|
||||
* 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). Read from the
|
||||
* feedback poll threads.
|
||||
* 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) {
|
||||
@@ -162,6 +184,50 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
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]).
|
||||
@@ -250,7 +316,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
/** 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 — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||
const val EXIT_HOLD_MS = 1500L
|
||||
/**
|
||||
* 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,
|
||||
): 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
|
||||
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||
@@ -151,6 +161,14 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
|
||||
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
|
||||
* [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it.
|
||||
* Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoCodecLabel(handle: Long): String
|
||||
|
||||
/**
|
||||
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
@@ -291,6 +309,14 @@ object NativeBridge {
|
||||
/** 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) ----
|
||||
|
||||
/**
|
||||
@@ -302,10 +328,11 @@ object NativeBridge {
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||
* byte count, or -1 on timeout / session closed.
|
||||
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
||||
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ fn run_sync(
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"decode: HEVC decoder started at {}x{}",
|
||||
"decode: {mime} decoder started at {}x{}",
|
||||
mode.width,
|
||||
mode.height
|
||||
);
|
||||
@@ -229,9 +229,11 @@ fn run_sync(
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
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
|
||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
||||
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||
// 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();
|
||||
// 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
|
||||
@@ -272,40 +274,45 @@ fn run_sync(
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
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)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
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
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts.
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
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)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) =
|
||||
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +352,8 @@ fn run_sync(
|
||||
};
|
||||
let (r, d) = drain(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&window,
|
||||
&mut applied_ds,
|
||||
wait,
|
||||
@@ -608,6 +617,19 @@ pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
|
||||
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
|
||||
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
|
||||
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
|
||||
pub(crate) fn codec_label(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "HEVC",
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
||||
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
||||
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
||||
@@ -806,7 +828,11 @@ fn run_async(
|
||||
})),
|
||||
on_error: Some(Box::new(move |e, code, _detail| {
|
||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
||||
log::warn!("decode: codec error {e:?} (fatal={fatal})");
|
||||
if fatal {
|
||||
log::error!("decode: fatal codec error — stream will stop: {e:?}");
|
||||
} else {
|
||||
log::warn!("decode: codec error {e:?} (recoverable)");
|
||||
}
|
||||
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||
})),
|
||||
};
|
||||
@@ -866,6 +892,9 @@ fn run_async(
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// 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()));
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
@@ -886,7 +915,15 @@ fn run_async(
|
||||
std::thread::Builder::new()
|
||||
.name("pf-decode-feed".into())
|
||||
.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()
|
||||
};
|
||||
@@ -976,6 +1013,8 @@ fn run_async(
|
||||
let had_output = !ready.is_empty();
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
@@ -1052,6 +1091,7 @@ fn run_async(
|
||||
fn feeder_loop(
|
||||
client: Arc<NativeClient>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
measure_decode: bool,
|
||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -1067,13 +1107,11 @@ fn feeder_loop(
|
||||
// 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);
|
||||
if stats.enabled() {
|
||||
// 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 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
|
||||
.lock()
|
||||
@@ -1083,19 +1121,27 @@ fn feeder_loop(
|
||||
g.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
}
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
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 {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,6 +1267,8 @@ fn feed_ready(
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
@@ -1234,12 +1282,22 @@ fn present_ready(
|
||||
if ready.is_empty() {
|
||||
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
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
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 —
|
||||
@@ -1460,6 +1518,8 @@ fn feed(
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||
fn drain(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
first_wait: Duration,
|
||||
@@ -1489,11 +1549,20 @@ fn drain(
|
||||
let flags = take_flags(recovery_flags, pts_us);
|
||||
held_present =
|
||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||
let meta = if stats.enabled() {
|
||||
let meta = if stats.enabled() || measure_decode {
|
||||
// The dequeue IS the sync loop's decoded-availability instant.
|
||||
let decoded_ns = now_realtime_ns();
|
||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
||||
Some((pts_us, decoded_ns))
|
||||
note_decoded_pts(
|
||||
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 {
|
||||
None
|
||||
};
|
||||
@@ -1564,6 +1633,8 @@ fn drain(
|
||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||
/// stamp (async loop).
|
||||
fn note_decoded_pts(
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
@@ -1582,12 +1653,25 @@ fn note_decoded_pts(
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
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 decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
// 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_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
|
||||
@@ -22,6 +22,7 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
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.
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||
@@ -143,6 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
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
|
||||
})
|
||||
|
||||
@@ -11,6 +11,43 @@ use std::time::Duration;
|
||||
|
||||
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.
|
||||
/// 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.
|
||||
@@ -185,6 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -318,7 +356,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
||||
Ok(host_fp) => hex32(&host_fp),
|
||||
Err(e) => {
|
||||
// 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}");
|
||||
note_error(&e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
//! 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).
|
||||
|
||||
use jni::objects::JObject;
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
@@ -236,3 +237,43 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +102,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
||||
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
|
||||
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
|
||||
@@ -19,5 +19,32 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
dropped; a bounded timer auto-disconnects. Never silence-as-keepalive (App Review 2.5.4).
|
||||
tvOS ignores/tolerates the key; macOS is not gated by it. -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<!-- Live Activities (iOS/iPadOS): the Lock-Screen / Dynamic-Island session surface. Updated
|
||||
locally (pushType nil) from the alive app process — no aps-environment. tvOS/macOS ignore it. -->
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<!-- Deep links: punktfunk://connect/<host-uuid>[?launch=<GameEntry.id>]. Emitted by the
|
||||
launcher widget and Siri/Shortcuts; routed by ContentView.onOpenURL into the existing
|
||||
connect path. Shared across all three targets (tvOS/macOS accept it harmlessly). -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>io.unom.punktfunk.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -73,5 +73,15 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||
</array>
|
||||
|
||||
<!-- App Group: same shared UserDefaults suite as iOS (Config/Punktfunk.entitlements). Shared
|
||||
here so a single HostStore code path (UserDefaults(suiteName:)) works on every platform;
|
||||
macOS widgets that read it arrive with M5. macOS App Groups use the plain group id under
|
||||
the App Store profile; a Developer-ID-signed build wants the team-prefixed form — the
|
||||
Dev-ID codesign step in release.yml must verify this value against the Dev-ID profile. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -20,5 +20,14 @@
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
<!-- App Group: the shared UserDefaults suite (group.io.unom.punktfunk) that both the app and
|
||||
the Widget/Live-Activity extension read — the saved-host store moved there so a launcher
|
||||
widget can see it (HostStore reads UserDefaults(suiteName:)). Must be registered on the
|
||||
developer portal and enabled in the provisioning profile for BOTH app ids
|
||||
(io.unom.punktfunk + io.unom.punktfunk.widgets). tvOS carries the key harmlessly. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,13 +9,20 @@ let package = Package(
|
||||
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
|
||||
products: [
|
||||
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
|
||||
// Dependency-free foundation (stored-host model + JSON codec, settings keys, App-Group
|
||||
// constant, deep-link grammar, Live Activity attributes). A separate PRODUCT so the widget
|
||||
// extension — which must never link PunktfunkKit (Rust staticlib + presentation layer) —
|
||||
// can link this and nothing else. PunktfunkKit re-exports it (see SharedReexport.swift).
|
||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||
],
|
||||
targets: [
|
||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||
// No dependencies by design — an extension process links this alone.
|
||||
.target(name: "PunktfunkShared"),
|
||||
.target(
|
||||
name: "PunktfunkKit",
|
||||
dependencies: ["PunktfunkCore"],
|
||||
dependencies: ["PunktfunkCore", "PunktfunkShared"],
|
||||
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||
// app, which links the PunktfunkKit product. Refresh with
|
||||
@@ -42,6 +49,14 @@ let package = Package(
|
||||
.executableTarget(name: "PunktfunkClient", 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"]),
|
||||
.testTarget(
|
||||
name: "PunktfunkKitTests",
|
||||
dependencies: ["PunktfunkKit", "PunktfunkShared", "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")
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -11,14 +11,56 @@
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E29556A7300948BA009F939C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA000000000000000000000D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E2955696300948B9009F939C;
|
||||
remoteInfo = PunktfunkWidgetsExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PunktfunkWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2955699300948B9009F939C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
E295569B300948B9009F939C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PunktfunkWidgetsExtension.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
AA0000000000000000000002 /* App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
@@ -30,6 +72,14 @@
|
||||
path = Sources/PunktfunkClient;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
E29556AD300948BA009F939C /* Exceptions for "PunktfunkWidgets" folder in "PunktfunkWidgetsExtension" target */,
|
||||
);
|
||||
path = PunktfunkWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -58,14 +108,27 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955694300948B9009F939C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */,
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */,
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA0000000000000000000007 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E295577B30094CE5009F939C /* PunktfunkWidgetsExtension.entitlements */,
|
||||
AA0000000000000000000002 /* App */,
|
||||
AA0000000000000000000003 /* Sources/PunktfunkClient */,
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||
E2955698300948B9009F939C /* Frameworks */,
|
||||
AA0000000000000000000008 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -76,10 +139,20 @@
|
||||
AA0000000000000000000001 /* Punktfunk.app */,
|
||||
BB0000000000000000000001 /* Punktfunk-iOS.app */,
|
||||
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
|
||||
E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2955698300948B9009F939C /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2955699300948B9009F939C /* WidgetKit.framework */,
|
||||
E295569B300948B9009F939C /* SwiftUI.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -114,10 +187,12 @@
|
||||
BB000000000000000000000B /* Sources */,
|
||||
BB0000000000000000000004 /* Frameworks */,
|
||||
BB000000000000000000000C /* Resources */,
|
||||
E29556AA300948BA009F939C /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E29556A8300948BA009F939C /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000002 /* App */,
|
||||
@@ -156,6 +231,29 @@
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */;
|
||||
buildPhases = (
|
||||
E2955693300948B9009F939C /* Sources */,
|
||||
E2955694300948B9009F939C /* Frameworks */,
|
||||
E2955695300948B9009F939C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
E295569D300948B9009F939C /* PunktfunkWidgets */,
|
||||
);
|
||||
name = PunktfunkWidgetsExtension;
|
||||
packageProductDependencies = (
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */,
|
||||
);
|
||||
productName = PunktfunkWidgetsExtension;
|
||||
productReference = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -163,11 +261,15 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2700;
|
||||
LastUpgradeCheck = 2700;
|
||||
TargetAttributes = {
|
||||
AA0000000000000000000009 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
E2955696300948B9009F939C = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA000000000000000000000E /* Build configuration list for PBXProject "Punktfunk" */;
|
||||
@@ -190,6 +292,7 @@
|
||||
AA0000000000000000000009 /* Punktfunk */,
|
||||
BB0000000000000000000009 /* Punktfunk-iOS */,
|
||||
CC0000000000000000000009 /* Punktfunk-tvOS */,
|
||||
E2955696300948B9009F939C /* PunktfunkWidgetsExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -216,6 +319,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955695300948B9009F939C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -240,8 +350,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E2955693300948B9009F939C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E29556A8300948BA009F939C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = E2955696300948B9009F939C /* PunktfunkWidgetsExtension */;
|
||||
targetProxy = E29556A7300948BA009F939C /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA0000000000000000000010 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -436,7 +561,6 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -478,7 +602,6 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -566,6 +689,97 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E29556AB300948BA009F939C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E29556AC300948BA009F939C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = PunktfunkWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -605,6 +819,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E29556AE300948BA009F939C /* Build configuration list for PBXNativeTarget "PunktfunkWidgetsExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E29556AB300948BA009F939C /* Debug */,
|
||||
E29556AC300948BA009F939C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
@@ -638,6 +861,10 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkKit;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
|
||||
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
|
||||
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
|
||||
// connect path (trust policy / WoL / approval all apply).
|
||||
//
|
||||
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
|
||||
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
|
||||
// (HostStore → WidgetCenter.reloadTimelines).
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct HostsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let hosts: [StoredHost]
|
||||
}
|
||||
|
||||
struct HostsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> HostsEntry {
|
||||
HostsEntry(date: .now, hosts: [])
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
|
||||
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
|
||||
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
|
||||
// changes (a new host, a fresh connect reordering by recency).
|
||||
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
|
||||
completion(Timeline(entries: [entry], policy: .never))
|
||||
}
|
||||
|
||||
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct HostsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
|
||||
HostsWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Punktfunk Hosts")
|
||||
.description("Quick-launch your recent streaming hosts.")
|
||||
.supportedFamilies([
|
||||
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
struct HostsWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: HostsEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
MediumHostsView(hosts: entry.hosts)
|
||||
case .accessoryCircular:
|
||||
CircularHostView(host: entry.hosts.first)
|
||||
case .accessoryRectangular:
|
||||
RectangularHostView(host: entry.hosts.first)
|
||||
default: // systemSmall + fallback
|
||||
SmallHostView(host: entry.hosts.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep link that connects to a stored host.
|
||||
private func connectURL(_ host: StoredHost) -> URL {
|
||||
DeepLink.connect(host: host.id, launchID: nil).url
|
||||
}
|
||||
|
||||
private struct SmallHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
if let host {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(connectURL(host))
|
||||
} else {
|
||||
EmptyHostView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediumHostsView: View {
|
||||
let hosts: [StoredHost]
|
||||
var body: some View {
|
||||
if hosts.isEmpty {
|
||||
EmptyHostView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Punktfunk")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(.tint)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(.tint)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct RectangularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
Text(host?.displayName ?? "Punktfunk")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyHostView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "play.tv")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Punktfunk to add a host.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets,
|
||||
// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and
|
||||
// NOTHING else — never PunktfunkKit (Rust staticlib + presentation layer would blow the widget
|
||||
// process's ~30 MB budget).
|
||||
//
|
||||
// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets
|
||||
// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension
|
||||
// target you add pointing at this folder — see design/apple-live-activities-and-widgets.md §M1 and
|
||||
// the GUI checklist.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
|
||||
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
|
||||
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
|
||||
//
|
||||
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
|
||||
// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via
|
||||
// Text(timerInterval:) — no per-second push.
|
||||
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
struct PunktfunkSessionLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
|
||||
LockScreenView(context: context)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen banner
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: View {
|
||||
let state: PunktfunkSessionAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
switch state.stage {
|
||||
case .streaming:
|
||||
EmptyView()
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Keeps running for")
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
badge("Running in background", .orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
badge("Reconnecting…", .yellow)
|
||||
case .ending:
|
||||
badge("Session ended", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func badge(_ text: String, _ color: Color) -> some View {
|
||||
Text(text).font(.caption2).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||
private struct EndButton: View {
|
||||
var body: some View {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
}
|
||||
.tint(.red)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.unom.punktfunk</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -46,10 +46,20 @@ struct ContentView: View {
|
||||
case "h264": return PunktfunkConnection.codecH264
|
||||
case "hevc": return PunktfunkConnection.codecHEVC
|
||||
case "av1": return PunktfunkConnection.codecAV1
|
||||
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
@State private var showAddHost = false
|
||||
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or
|
||||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
||||
/// "Connection failed" one, which is for actual connect errors).
|
||||
@State private var deepLinkNotice: String?
|
||||
#if os(iOS)
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@State private var liveActivity = SessionActivityController()
|
||||
#endif
|
||||
@State private var pairingTarget: StoredHost?
|
||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||
@@ -91,6 +101,14 @@ struct ContentView: View {
|
||||
/// 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
|
||||
/// Background keep-alive (Settings → General, iOS-only). Default OFF (today's freeze-on-background
|
||||
/// is the default). When on, backgrounding a live session keeps audio + the connection alive and
|
||||
/// drops video, auto-disconnecting after `backgroundTimeoutMinutes`.
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10
|
||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
@@ -112,7 +130,62 @@ struct ContentView: View {
|
||||
.onAppear {
|
||||
seedDefaultModeIfNeeded()
|
||||
autoConnectIfAsked()
|
||||
#if os(iOS)
|
||||
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
|
||||
#endif
|
||||
}
|
||||
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
|
||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||
// parallel session — this drives the one `model` ContentView owns.
|
||||
.onOpenURL { handleDeepLink($0) }
|
||||
#if os(iOS)
|
||||
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
||||
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background:
|
||||
if backgroundKeepAlive, model.phase == .streaming {
|
||||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||||
}
|
||||
case .active:
|
||||
model.exitBackground()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// Live Activity lifecycle, driven from the model's published state.
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
if let host = model.activeHost {
|
||||
liveActivity.begin(
|
||||
hostID: host.id, hostName: host.displayName,
|
||||
launchTitle: nil, // no live foreground-app title mid-session (v1)
|
||||
modeLine: currentModeLine(), startedAt: Date())
|
||||
}
|
||||
case .idle:
|
||||
liveActivity.end()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: model.isBackgrounded) { _, backgrounded in
|
||||
liveActivity.update {
|
||||
$0.stage = backgrounded ? .background : .streaming
|
||||
$0.backgroundDeadline = model.backgroundDeadline
|
||||
}
|
||||
}
|
||||
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
||||
// this — tear the session down deliberately (quit-close the host).
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
||||
model.disconnect(deliberate: true)
|
||||
}
|
||||
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler
|
||||
// as a widget tap.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
||||
if let url = note.object as? URL { handleDeepLink(url) }
|
||||
}
|
||||
#endif
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
@@ -261,6 +334,57 @@ struct ContentView: View {
|
||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
// Informational deep-link outcome (unknown host / already streaming). Not an error.
|
||||
.alert(
|
||||
"Can't open",
|
||||
isPresented: Binding(
|
||||
get: { deepLinkNotice != nil },
|
||||
set: { if !$0 { deepLinkNotice = nil } })
|
||||
) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
||||
private func currentModeLine() -> String {
|
||||
guard let c = model.connection else { return "" }
|
||||
let codec: String
|
||||
switch c.videoCodec {
|
||||
case .h264: codec = "H.264"
|
||||
case .hevc: codec = "HEVC"
|
||||
case .av1: codec = "AV1"
|
||||
case .pyrowave: codec = "PyroWave"
|
||||
}
|
||||
var line = "\(c.width)×\(c.height)"
|
||||
if c.refreshHz > 0 { line += " @\(c.refreshHz)" }
|
||||
line += " · \(codec)"
|
||||
if c.isHDR { line += " · HDR" }
|
||||
return line
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design):
|
||||
/// unknown host → notice + no-op; a live session is up → ignore if it's the same host, else
|
||||
/// tell the user to end the current one first (NEVER tear down a live session on a background
|
||||
/// tap); otherwise the normal `connect` — trust policy, WoL and the approval sheet all apply.
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return }
|
||||
guard let host = store.hosts.first(where: { $0.id == hostID }) else {
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
return
|
||||
}
|
||||
if model.phase != .idle {
|
||||
guard model.activeHost?.id == hostID else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
connect(host, launchID: launchID)
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an
|
||||
// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process
|
||||
// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
//
|
||||
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent
|
||||
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the
|
||||
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in
|
||||
// PunktfunkShared so the widget's configuration intent can share it.
|
||||
|
||||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
|
||||
/// Load a full saved host (MACs, address) from the shared App-Group store by id — HostEntity only
|
||||
/// carries id + name.
|
||||
private func loadStoredHost(_ id: UUID) -> StoredHost? {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return nil }
|
||||
return hosts.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and
|
||||
/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval
|
||||
/// sheet all apply, and its guards (unknown host, already-streaming) hold.
|
||||
struct ConnectToHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Connect to Host"
|
||||
static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
@Parameter(title: "Game ID", description: "Optional store id like steam:570")
|
||||
var launchID: String?
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let url = DeepLink.connect(host: host.id, launchID: launchID).url
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
||||
/// home, wake the tower") without foregrounding the app.
|
||||
struct WakeHostIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Wake Host"
|
||||
static let description = IntentDescription("Send a Wake-on-LAN magic packet to a host.")
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let stored = loadStoredHost(host.id), !stored.wakeMacs.isEmpty else {
|
||||
throw IntentError.noWakeAddress
|
||||
}
|
||||
PunktfunkConnection.wakeOnLAN(macs: stored.wakeMacs, lastKnownIP: stored.address)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to Siri/Shortcuts. `CustomLocalizedStringResourceConvertible` makes the message
|
||||
/// show as the intent's failure text.
|
||||
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case noWakeAddress
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .noWakeAddress:
|
||||
// One string LITERAL — LocalizedStringResource is ExpressibleByStringLiteral, but a
|
||||
// `"…" + "…"` concatenation is a runtime String it can't convert.
|
||||
return "That host has no saved Wake-on-LAN address yet. Connect to it once so Punktfunk can learn it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
|
||||
/// well under the 10-shortcut cap.
|
||||
struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: ConnectToHostIntent(),
|
||||
phrases: [
|
||||
"Connect to \(\.$host) in \(.applicationName)",
|
||||
"Stream \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||
AppShortcut(
|
||||
intent: WakeHostIntent(),
|
||||
phrases: [
|
||||
"Wake \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Wake Host", systemImageName: "power")
|
||||
AppShortcut(
|
||||
intent: EndStreamIntent(),
|
||||
phrases: [
|
||||
"End the \(.applicationName) stream",
|
||||
],
|
||||
shortTitle: "End Stream", systemImageName: "stop.fill")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,89 @@
|
||||
// Owns the ActivityKit Live Activity lifecycle for a streaming session (iPhone/iPad only). Driven
|
||||
// by ContentView from the session model's published state (phase / isBackgrounded / deadline) so
|
||||
// none of this leaks into the cross-platform SessionModel. Local updates only (`pushType: nil`) —
|
||||
// the app process is alive whenever there's a session to report, so there's no push token plumbing.
|
||||
//
|
||||
// Gated os(iOS): ActivityKit is iPhone/iPad only. Minimum deployment is iOS 17, so no @available
|
||||
// guards are needed (Activity has existed since 16.1).
|
||||
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
// PunktfunkKit re-exports PunktfunkShared (@_exported), so the app target sees PunktfunkSessionAttributes
|
||||
// without linking the Shared product directly — same pattern as StoredHost in HostStore.
|
||||
import PunktfunkKit
|
||||
|
||||
@MainActor
|
||||
final class SessionActivityController {
|
||||
private var activity: Activity<PunktfunkSessionAttributes>?
|
||||
/// The last pushed state, so an update can mutate one field and keep the rest (notably
|
||||
/// `startedAt`, which the Lock-Screen timer ticks from).
|
||||
private var state: PunktfunkSessionAttributes.ContentState?
|
||||
|
||||
/// How far past the next expected update to mark the content stale — a frozen opt-out session
|
||||
/// then greys out instead of showing a lying clock.
|
||||
private static let staleWindow: TimeInterval = 90
|
||||
|
||||
var isActive: Bool { activity != nil }
|
||||
|
||||
/// End any Activity left over from a previous launch that was killed mid-session. Call once at
|
||||
/// app start (ContentView.onAppear).
|
||||
static func sweepOrphans() {
|
||||
Task {
|
||||
for activity in Activity<PunktfunkSessionAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Live Activity for a freshly-streaming session. No-op if the user disabled Live
|
||||
/// Activities for the app, or one is already up.
|
||||
func begin(hostID: UUID, hostName: String, launchTitle: String?, modeLine: String, startedAt: Date) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled, activity == nil else { return }
|
||||
let attributes = PunktfunkSessionAttributes(
|
||||
hostID: hostID, hostName: hostName, launchTitle: launchTitle)
|
||||
let initial = PunktfunkSessionAttributes.ContentState(
|
||||
stage: .streaming, startedAt: startedAt, modeLine: modeLine)
|
||||
state = initial
|
||||
do {
|
||||
activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: content(initial),
|
||||
pushType: nil)
|
||||
} catch {
|
||||
activity = nil
|
||||
state = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalesced update: mutate the running state in place (keeps `startedAt` etc.) and push once.
|
||||
/// No-op when there's no live Activity.
|
||||
func update(_ mutate: (inout PunktfunkSessionAttributes.ContentState) -> Void) {
|
||||
guard let activity, var next = state else { return }
|
||||
mutate(&next)
|
||||
state = next
|
||||
Task { await activity.update(content(next)) }
|
||||
}
|
||||
|
||||
/// End with a final "ended" state, dismissed a few seconds later.
|
||||
func end() {
|
||||
guard let activity, var final = state else {
|
||||
self.activity = nil
|
||||
state = nil
|
||||
return
|
||||
}
|
||||
self.activity = nil
|
||||
state = nil
|
||||
final.stage = .ending
|
||||
final.backgroundDeadline = nil
|
||||
Task {
|
||||
await activity.end(content(final), dismissalPolicy: .after(.now + 4))
|
||||
}
|
||||
}
|
||||
|
||||
private func content(_ s: PunktfunkSessionAttributes.ContentState)
|
||||
-> ActivityContent<PunktfunkSessionAttributes.ContentState> {
|
||||
ActivityContent(state: s, staleDate: Date().addingTimeInterval(Self.staleWindow))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -148,6 +148,16 @@ final class SessionModel: ObservableObject {
|
||||
|
||||
var isBusy: Bool { phase != .idle }
|
||||
|
||||
/// True while a streaming session is running in the background under the opt-in keep-alive
|
||||
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
|
||||
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
|
||||
@Published private(set) var isBackgrounded = false
|
||||
/// When the backgrounded keep-alive will auto-disconnect (nil unless backgrounded) — drives the
|
||||
/// Live Activity countdown. Set alongside `backgroundTimer`.
|
||||
@Published private(set) var backgroundDeadline: Date?
|
||||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
||||
private var backgroundTimer: DispatchSourceTimer?
|
||||
|
||||
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
||||
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
||||
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
||||
@@ -239,6 +249,18 @@ final class SessionModel: ObservableObject {
|
||||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||
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(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
@@ -284,10 +306,15 @@ final class SessionModel: ObservableObject {
|
||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||
+ "Pair with its PIN before streaming."
|
||||
}
|
||||
case .failure:
|
||||
case .failure(let error):
|
||||
self.phase = .idle
|
||||
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
|
||||
// own overlay explains what's happening.
|
||||
onUnreachable()
|
||||
@@ -315,6 +342,48 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background keep-alive (opt-in, iOS)
|
||||
|
||||
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
|
||||
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
|
||||
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
|
||||
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
|
||||
func enterBackground(timeoutMinutes: Int) {
|
||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||||
isBackgrounded = true
|
||||
conn.setVideoDropped(true)
|
||||
audio?.setMicMuted(true)
|
||||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
||||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
||||
let minutes = max(1, timeoutMinutes)
|
||||
backgroundDeadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||
timer.schedule(deadline: .now() + .seconds(minutes * 60))
|
||||
timer.setEventHandler { [weak self] in
|
||||
// The timer fires on `.main`, so the actor's executor is the main thread here.
|
||||
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
|
||||
}
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = timer
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor —
|
||||
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
|
||||
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
|
||||
func exitBackground() {
|
||||
guard isBackgrounded else { return }
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
audio?.setMicMuted(false)
|
||||
if let conn = connection {
|
||||
conn.setVideoDropped(false)
|
||||
conn.requestKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
|
||||
func confirmTrust() -> Data? {
|
||||
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
|
||||
@@ -332,6 +401,11 @@ final class SessionModel: ObservableObject {
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
|
||||
@@ -15,6 +15,9 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
#if os(iOS)
|
||||
import CoreHaptics
|
||||
#endif
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -38,6 +41,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@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
|
||||
|
||||
#if os(iOS)
|
||||
@@ -230,7 +236,7 @@ struct GamepadSettingsView: View {
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
return [
|
||||
var list: [Row] = [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
@@ -329,6 +335,23 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
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
|
||||
|
||||
@@ -79,6 +79,13 @@ enum SettingsOptions {
|
||||
if AV1.hardwareDecodeSupported {
|
||||
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
|
||||
}()
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||
// composed by the per-platform bodies in SettingsView.swift.
|
||||
|
||||
#if os(iOS)
|
||||
import CoreHaptics
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@@ -437,6 +440,34 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
|
||||
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
|
||||
/// detail can reference it unconditionally.
|
||||
@ViewBuilder var keepAliveSection: some View {
|
||||
#if os(iOS)
|
||||
Section {
|
||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||
if backgroundKeepAlive {
|
||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||
Text("1 minute").tag(1)
|
||||
Text("5 minutes").tag(5)
|
||||
Text("10 minutes").tag(10)
|
||||
Text("30 minutes").tag(30)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Background")
|
||||
} footer: {
|
||||
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
|
||||
+ "playing and the connection stays live (video is dropped to save power) after you "
|
||||
+ "switch away — and the session auto-disconnects after the time above so it can't "
|
||||
+ "run down your battery. Returning to the app resumes video instantly.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var experimentalSection: some View {
|
||||
Section {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
@@ -471,6 +502,12 @@ extension SettingsView {
|
||||
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)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
#endif
|
||||
@@ -487,6 +524,11 @@ extension SettingsView {
|
||||
// for its own footer and has no such toggle to describe.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(Self.controllersFooter)
|
||||
#if os(iOS)
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Text(Self.deviceRumbleFooter)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Text(Self.gamepadUIFooter)
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,13 @@ extension SettingsView {
|
||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||
+ "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)
|
||||
static let gamepadUIFooter =
|
||||
"When a controller connects, the host list and library switch to a controller-"
|
||||
|
||||
@@ -49,12 +49,15 @@ struct SettingsView: View {
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@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.
|
||||
// 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).
|
||||
@@ -241,6 +244,7 @@ struct SettingsView: View {
|
||||
pointerSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
|
||||
@@ -11,32 +11,13 @@
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
struct StoredHost: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var address: String
|
||||
var port: UInt16 = 9777
|
||||
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||
var pinnedSHA256: Data?
|
||||
/// Last time a streaming session actually started (nil until the first one).
|
||||
var lastConnected: Date?
|
||||
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
var macAddresses: [String]?
|
||||
|
||||
var displayName: String { name.isEmpty ? address : name }
|
||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
// `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension
|
||||
// can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here
|
||||
// because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`.
|
||||
|
||||
extension StoredHost {
|
||||
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
||||
@@ -86,8 +67,14 @@ final class HostStore: ObservableObject {
|
||||
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
|
||||
@Published var probedOnline: Set<StoredHost.ID> = []
|
||||
|
||||
/// The App-Group suite — shared with the Widget/Live-Activity extension so a launcher widget
|
||||
/// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see
|
||||
/// `AppGroup.defaults`).
|
||||
private let defaults = AppGroup.defaults
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: Self.key),
|
||||
Self.migrateToAppGroupIfNeeded()
|
||||
if let data = defaults.data(forKey: Self.key),
|
||||
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
|
||||
hosts = decoded
|
||||
} else {
|
||||
@@ -95,6 +82,20 @@ final class HostStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before
|
||||
/// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no
|
||||
/// hosts yet but standard does. The old value is LEFT in place — during a staged TestFlight
|
||||
/// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from
|
||||
/// the not-yet-updated app. Remove the standard copy a release later.
|
||||
private static func migrateToAppGroupIfNeeded() {
|
||||
let suite = AppGroup.defaults
|
||||
let standard = UserDefaults.standard
|
||||
guard suite !== standard else { return } // un-entitled fallback: nothing to migrate
|
||||
guard suite.data(forKey: key) == nil,
|
||||
let legacy = standard.data(forKey: key) else { return }
|
||||
suite.set(legacy, forKey: key)
|
||||
}
|
||||
|
||||
func add(_ host: StoredHost) {
|
||||
hosts.append(host)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ final class HostStore: ObservableObject {
|
||||
|
||||
func markConnected(_ hostID: UUID) {
|
||||
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
|
||||
hosts[i].lastConnected = Date()
|
||||
hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget
|
||||
}
|
||||
|
||||
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
|
||||
@@ -158,7 +159,17 @@ final class HostStore: ObservableObject {
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
UserDefaults.standard.set(data, forKey: Self.key)
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
||||
}
|
||||
|
||||
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
|
||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
||||
/// `.never`-refresh entries and relies on this push.
|
||||
private func reloadHostsWidget() {
|
||||
#if canImport(WidgetKit) && os(iOS)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,14 +212,18 @@ struct PairSheet: View {
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||
+ "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):
|
||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||
+ "pairing would not survive a relaunch. Unlock the login "
|
||||
+ "keychain and try again."
|
||||
case .failure:
|
||||
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
||||
+ "(web console → Pairing), and not mid-session? Retries are "
|
||||
+ "rate-limited to one per 2 seconds."
|
||||
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||
+ "isolation)?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,23 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio
|
||||
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
|
||||
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
|
||||
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
|
||||
/// — this stops the actual capture, which is the privacy-relevant part. Main thread.
|
||||
public func setMicMuted(_ muted: Bool) {
|
||||
stateLock.lock()
|
||||
let capture = captureEngine
|
||||
stateLock.unlock()
|
||||
guard let capture else { return }
|
||||
if muted {
|
||||
capture.pause()
|
||||
} else if !flag.isStopped {
|
||||
try? capture.start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
|
||||
@@ -54,6 +54,12 @@ public func pair(
|
||||
switch rc {
|
||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
// `punktfunkDefaultMgmtPort` (and StoredHost/DefaultsKey) now live in PunktfunkShared so the
|
||||
// dependency-free widget extension can share them; PunktfunkKit re-exports the module.
|
||||
import PunktfunkShared
|
||||
|
||||
/// Cover art URLs (the public Steam CDN for Steam titles, user-supplied for custom entries).
|
||||
public struct Artwork: Codable, Hashable, Sendable {
|
||||
@@ -64,10 +67,6 @@ public enum LibraryError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The management API's default port — adjacent to the GameStream block; matches
|
||||
/// `mgmt::DEFAULT_PORT` on the host.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
/// Stateless fetcher for a host's library.
|
||||
public enum LibraryClient {
|
||||
/// `GET https://<address>:<port>/api/v1/library`, authenticated by **mTLS**: the client
|
||||
|
||||
@@ -59,6 +59,68 @@ public enum PunktfunkClientError: Error {
|
||||
case wrongPIN
|
||||
case closed
|
||||
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.
|
||||
@@ -188,6 +250,19 @@ public final class PunktfunkConnection {
|
||||
// exist so the resolved type round-trips and name parsing matches the host.
|
||||
case steamController = 5
|
||||
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
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -200,6 +275,11 @@ public final class PunktfunkConnection {
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -257,9 +337,15 @@ public final class PunktfunkConnection {
|
||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||
|
||||
/// 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
|
||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1, `8` = PyroWave (only when this
|
||||
/// 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
|
||||
|
||||
/// 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
|
||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||
@@ -301,6 +387,10 @@ public final class PunktfunkConnection {
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
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
|
||||
// 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
|
||||
@@ -311,24 +401,29 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex7(
|
||||
punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
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,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
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)
|
||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||
@@ -363,6 +458,9 @@ public final class PunktfunkConnection {
|
||||
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
_ = punktfunk_connection_codec(handle, &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`.
|
||||
@@ -436,6 +534,23 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Background-keep-alive video drop (opt-in). While true, both video pumps keep DRAINING
|
||||
/// `nextAU()` (so QUIC flow control and host pacing stay healthy) but DISCARD each AU before any
|
||||
/// VideoToolbox/Metal decode or render — the crash/jetsam-safe way to hold a backgrounded
|
||||
/// session (audio keeps rendering; no GPU work off-screen). Set on `SessionModel.enterBackground`,
|
||||
/// cleared on `exitBackground` (which then requests a fresh IDR; the pump's re-anchor gate
|
||||
/// auto-arms on the resumed frame-index gap). Its own tiny lock — read on the pump thread every
|
||||
/// iteration, written on the main actor; never contends the ABI/plane locks.
|
||||
private let videoDropLock = NSLock()
|
||||
private var videoDropped = false
|
||||
public var isVideoDropped: Bool {
|
||||
videoDropLock.lock(); defer { videoDropLock.unlock() }
|
||||
return videoDropped
|
||||
}
|
||||
public func setVideoDropped(_ dropped: Bool) {
|
||||
videoDropLock.lock(); videoDropped = dropped; videoDropLock.unlock()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -480,6 +595,30 @@ public final class PunktfunkConnection {
|
||||
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).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
@@ -701,6 +840,15 @@ public final class PunktfunkConnection {
|
||||
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||
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
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
// (triggers off, player index unset) and its renderer silenced.
|
||||
|
||||
import Combine
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
public final class GamepadFeedback {
|
||||
private let connection: PunktfunkConnection
|
||||
@@ -50,9 +52,26 @@ public final class GamepadFeedback {
|
||||
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) {
|
||||
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
|
||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -189,6 +208,7 @@ public final class GamepadFeedback {
|
||||
return r
|
||||
}
|
||||
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
|
||||
@@ -203,6 +223,10 @@ public final class GamepadFeedback {
|
||||
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 {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import GameController
|
||||
import PunktfunkShared
|
||||
|
||||
@MainActor
|
||||
public final class GamepadManager: ObservableObject {
|
||||
@@ -42,13 +43,14 @@ public final class GamepadManager: ObservableObject {
|
||||
public let hasHaptics: Bool
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||
/// player LEDs) and the PlayStation glyph in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||
/// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
|
||||
/// in Settings.
|
||||
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).
|
||||
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).
|
||||
public let batteryLevel: Float?
|
||||
@@ -227,7 +229,7 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
let extended = c.extendedGamepad
|
||||
let kind = padKind(extended)
|
||||
let kind = padKind(extended, productCategory: c.productCategory)
|
||||
return DiscoveredController(
|
||||
id: id,
|
||||
name: c.vendorName ?? c.productCategory,
|
||||
@@ -237,28 +239,40 @@ public final class GamepadManager: ObservableObject {
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||
// DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||
// Edge included); the DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||
isCharging: c.battery?.batteryState == .charging,
|
||||
controller: c)
|
||||
}
|
||||
|
||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||
/// subclass (+ the product-category string where the subclass is shared). Detection order
|
||||
/// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
|
||||
/// `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(
|
||||
_ extended: GCExtendedGamepad?
|
||||
_ extended: GCExtendedGamepad?,
|
||||
productCategory: String
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
guard let extended else { return .xbox360 }
|
||||
let category = productCategory.lowercased()
|
||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||
// here, so no `@available` guard is needed — matching the unguarded
|
||||
// `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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
|
||||
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
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 policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// 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)
|
||||
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session) {
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
/// `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
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
private func setup() {
|
||||
if actuator == .device {
|
||||
setupDevice()
|
||||
return
|
||||
}
|
||||
guard let haptics = controller?.haptics else {
|
||||
// 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
|
||||
@@ -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(
|
||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||
) -> Motor? {
|
||||
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
|
||||
// (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
|
||||
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
try engine.start()
|
||||
return Motor(engine: engine, sharpness: sharpness)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,44 @@ extension InputCapture {
|
||||
]
|
||||
#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
|
||||
// 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
|
||||
// (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
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
@@ -18,6 +19,7 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import UIKit
|
||||
|
||||
/// How touchscreen fingers drive the host — persisted under `DefaultsKey.touchMode`, latched
|
||||
@@ -61,6 +63,9 @@ final class TouchMouse {
|
||||
static let accelGain: CGFloat = 0.6
|
||||
static let accelSpeedFloor: CGFloat = 0.3
|
||||
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.
|
||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||
@@ -72,6 +77,9 @@ final class TouchMouse {
|
||||
var send: ((PunktfunkInputEvent) -> Void)?
|
||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||
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.
|
||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||
@@ -95,6 +103,11 @@ final class TouchMouse {
|
||||
private var carryY: CGFloat = 0
|
||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||
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.
|
||||
private var lastTapUp: TimeInterval = 0
|
||||
private var lastTapPoint = CGPoint.zero
|
||||
@@ -114,6 +127,8 @@ final class TouchMouse {
|
||||
maxFingers = 0
|
||||
moved = false
|
||||
scrolling = false
|
||||
kbCount = 0
|
||||
kbFired = false
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||
@@ -140,8 +155,13 @@ final class TouchMouse {
|
||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||
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()
|
||||
} else if lastPos.count >= 3 {
|
||||
keyboardSwipe(in: view)
|
||||
} else if !scrolling, let touch = touches.first(where: {
|
||||
lastPos[ObjectIdentifier($0)] != nil
|
||||
}) {
|
||||
@@ -208,9 +228,9 @@ final class TouchMouse {
|
||||
|
||||
// MARK: - Per-event work
|
||||
|
||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
||||
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
||||
/// scrolls right (the host WHEEL(120) convention).
|
||||
private func scrollByCentroid() {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
@@ -233,6 +253,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 must not jerk the cursor).
|
||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// PunktfunkShared holds what the app AND the widget extension both need — the stored-host model,
|
||||
// the settings-key names, the App-Group constant, the deep-link grammar, and the Live Activity
|
||||
// attributes — in a module that links neither the Rust core nor the presentation layer.
|
||||
//
|
||||
// Re-export it so every existing consumer of PunktfunkKit (`import PunktfunkKit`) keeps seeing
|
||||
// `StoredHost`, `DefaultsKey`, `punktfunkDefaultMgmtPort`, `DeepLink`, etc. with no call-site churn.
|
||||
// (Files INSIDE PunktfunkKit still `import PunktfunkShared` explicitly — Swift imports are
|
||||
// file-scoped; the re-export only reaches downstream modules.)
|
||||
@_exported import PunktfunkShared
|
||||
@@ -8,6 +8,7 @@
|
||||
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||
/// rename the cases freely, never the strings.
|
||||
|
||||
@@ -543,19 +543,24 @@ public enum AV1 {
|
||||
|
||||
extension VideoCodec {
|
||||
/// 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? {
|
||||
self == .av1
|
||||
? AV1.formatDescription(fromKeyframe: au)
|
||||
: AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||
switch self {
|
||||
case .av1: return AV1.formatDescription(fromKeyframe: au)
|
||||
case .pyrowave: return nil
|
||||
default: return AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
||||
public func sampleBuffer(
|
||||
au: AccessUnit, format: CMVideoFormatDescription
|
||||
) -> CMSampleBuffer? {
|
||||
self == .av1
|
||||
? AV1.sampleBuffer(au: au, format: format)
|
||||
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||
switch self {
|
||||
case .av1: return AV1.sampleBuffer(au: au, format: format)
|
||||
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 hevc
|
||||
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).
|
||||
public init(wire: UInt8) {
|
||||
switch wire {
|
||||
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -147,8 +153,8 @@ public enum AnnexB {
|
||||
sets = [vps, sps, pps]
|
||||
case .h264:
|
||||
sets = [sps, pps]
|
||||
case .av1:
|
||||
return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here
|
||||
case .av1, .pyrowave:
|
||||
return nil // no parameter-set NALs — dispatched in AV1.swift, never reaches here
|
||||
}
|
||||
|
||||
var format: CMVideoFormatDescription?
|
||||
@@ -184,8 +190,8 @@ public enum AnnexB {
|
||||
parameterSetSizes: sizes,
|
||||
nalUnitHeaderLength: 4,
|
||||
formatDescriptionOut: &format)
|
||||
case .av1:
|
||||
break // unreachable — the .av1 arm above already returned
|
||||
case .av1, .pyrowave:
|
||||
break // unreachable — the arm above already returned
|
||||
}
|
||||
}
|
||||
return status == noErr ? format : nil
|
||||
|
||||
@@ -149,6 +149,28 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
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 —
|
||||
// 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
|
||||
@@ -215,8 +237,16 @@ public final class MetalVideoPresenter {
|
||||
/// 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.
|
||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
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.
|
||||
/// 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).
|
||||
@@ -258,6 +288,7 @@ public final class MetalVideoPresenter {
|
||||
let pipelineSDR: MTLRenderPipelineState
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
let pipelinePlanar: MTLRenderPipelineState
|
||||
do {
|
||||
// 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
|
||||
@@ -292,6 +323,11 @@ public final class MetalVideoPresenter {
|
||||
#else
|
||||
pipelineHDRToneMap = nil
|
||||
#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 {
|
||||
return nil
|
||||
}
|
||||
@@ -331,12 +367,14 @@ public final class MetalVideoPresenter {
|
||||
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||
textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
private init(
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||
pipelinePlanar: MTLRenderPipelineState,
|
||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
@@ -344,6 +382,7 @@ public final class MetalVideoPresenter {
|
||||
self.pipelineSDR = pipelineSDR
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||
self.pipelinePlanar = pipelinePlanar
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
@@ -514,6 +553,67 @@ public final class MetalVideoPresenter {
|
||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||
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
|
||||
// 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:
|
||||
@@ -522,8 +622,6 @@ public final class MetalVideoPresenter {
|
||||
// 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
|
||||
// (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)
|
||||
? targetFromLayout : decodedSize
|
||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||
@@ -542,17 +640,8 @@ public final class MetalVideoPresenter {
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) 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)
|
||||
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.setRenderPipelineState(pipeline)
|
||||
bind(encoder)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
if let onPresented {
|
||||
@@ -580,9 +669,8 @@ public final class MetalVideoPresenter {
|
||||
} else {
|
||||
commandBuffer.present(drawable)
|
||||
}
|
||||
// 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.
|
||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
commandBuffer.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -9,6 +9,7 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
#if os(tvOS)
|
||||
import UIKit
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Metal
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||
@@ -249,6 +251,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 {
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
@@ -257,8 +281,12 @@ public final class Stage2Pipeline {
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
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).
|
||||
@@ -306,11 +334,13 @@ public final class Stage2Pipeline {
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
let decodeReport = decodeReport
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
@@ -318,6 +348,10 @@ public final class Stage2Pipeline {
|
||||
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||
decodeMeter?.record(
|
||||
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
|
||||
@@ -346,6 +380,7 @@ public final class Stage2Pipeline {
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
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)
|
||||
|
||||
@@ -362,7 +397,21 @@ public final class Stage2Pipeline {
|
||||
let presenter = presenter
|
||||
let pumpStopped = pumpStopped
|
||||
let reanchorGate = gate
|
||||
let thread = Thread {
|
||||
// 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()
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||
@@ -383,6 +432,15 @@ public final class Stage2Pipeline {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain one AU (flow control + host pacing) and discard it
|
||||
// BEFORE any VideoToolbox decode or Metal render — no GPU work off-screen. The
|
||||
// decoder session is left intact; exitBackground requests a fresh IDR and the
|
||||
// re-anchor gate arms on the resumed frame-index gap so concealed frames are
|
||||
// withheld until it lands.
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||
@@ -445,6 +503,7 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread.name = "punktfunk-stage2-pump"
|
||||
thread.qualityOfService = .userInteractive
|
||||
@@ -504,9 +563,7 @@ public final class Stage2Pipeline {
|
||||
let presentAt = vsyncEnabled
|
||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||
let renderStarted = CACurrentMediaTime()
|
||||
let rendered = presenter.render(
|
||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||
) { presentedNs in
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
// 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.
|
||||
if let gate {
|
||||
@@ -525,6 +582,18 @@ public final class Stage2Pipeline {
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
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(
|
||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||
if !rendered {
|
||||
@@ -592,6 +661,100 @@ public final class Stage2Pipeline {
|
||||
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 {
|
||||
// Background keep-alive: drain + discard before the Metal wavelet decode
|
||||
// (PyroWave is all-intra, so the resumed frame heals on its own — no IDR
|
||||
// request needed, just no GPU work off-screen).
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
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`
|
||||
/// 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.
|
||||
|
||||
@@ -63,6 +63,14 @@ final class StreamPump {
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Background keep-alive: drain one AU to keep QUIC flow control + host pacing
|
||||
// healthy, then discard it BEFORE any decode/enqueue — no VideoToolbox/Metal work
|
||||
// off-screen. Skips all recovery/gate bookkeeping too; exitBackground requests a
|
||||
// fresh IDR and the re-anchor gate re-arms on the resumed frame-index gap.
|
||||
if connection.isVideoDropped {
|
||||
_ = try connection.nextAU(timeoutMs: 100)
|
||||
return true
|
||||
}
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||
|
||||
@@ -12,7 +12,23 @@ import CoreVideo
|
||||
import Foundation
|
||||
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 {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
@@ -22,15 +38,26 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
/// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
|
||||
public let pixelBuffer: CVPixelBuffer
|
||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
||||
public let isHDR: Bool
|
||||
/// The decoded image and which present path it takes.
|
||||
public let image: ReadyImage
|
||||
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||
/// 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
|
||||
@@ -286,6 +313,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
onDecoded(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags))
|
||||
image: .video(imageBuffer, isHDR: isHDR), flags: flags))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import AVFoundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
@@ -698,6 +699,7 @@ final class StreamLayerUIView: UIView {
|
||||
let mouse = TouchMouse()
|
||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
||||
return mouse
|
||||
}()
|
||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||
@@ -708,6 +710,22 @@ final class StreamLayerUIView: UIView {
|
||||
func resetTouchInput() {
|
||||
touchMouse.reset()
|
||||
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
|
||||
|
||||
@@ -879,4 +897,46 @@ final class StreamLayerUIView: UIView {
|
||||
}
|
||||
#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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// The App-Group foundation shared by the app and its extensions (Widgets / Live Activity).
|
||||
//
|
||||
// PunktfunkShared is deliberately dependency-free: it links NEITHER PunktfunkKit (which drags in
|
||||
// the Rust staticlib + presentation layer) NOR any Apple UI framework. A widget process gets ~30 MB,
|
||||
// so everything an extension needs — the stored-host model + its JSON codec, the settings-key names,
|
||||
// the deep-link grammar, and (later) the Live Activity attributes — lives here and here only.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The one App-Group identifier, matched by `Config/*.entitlements`
|
||||
/// (`com.apple.security.application-groups`). Registered on the developer portal for both the app
|
||||
/// id (`io.unom.punktfunk`) and the widget extension id (`io.unom.punktfunk.widgets`).
|
||||
public enum AppGroup {
|
||||
public static let suiteName = "group.io.unom.punktfunk"
|
||||
|
||||
/// The shared defaults suite. Non-nil in a correctly-entitled process; falls back to
|
||||
/// `.standard` if the group is somehow unavailable (unsigned `swift run`, a misprovisioned
|
||||
/// build) so the app still functions single-process rather than crashing — the widget just
|
||||
/// won't see the same store there.
|
||||
public static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// The `punktfunk://` deep-link grammar — the single builder/parser shared by the widget (which
|
||||
// emits links via `widgetURL`/`Link`) and the app (`ContentView.onOpenURL`, which routes them into
|
||||
// the existing connect path). Keeping both sides on one type means the wire format can't drift.
|
||||
//
|
||||
// Grammar (v1):
|
||||
// punktfunk://connect/<host-uuid> — connect to a stored host
|
||||
// punktfunk://connect/<host-uuid>?launch=<GameEntry.id> — connect and ask the host to launch it
|
||||
//
|
||||
// `launch` carries a `GameEntry.id` (e.g. "steam:570"); it is percent-encoded on build and decoded
|
||||
// on parse, so ids with reserved characters survive the round trip.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DeepLink: Equatable {
|
||||
/// Connect to a saved host; `launchID` is a `GameEntry.id` to launch on arrival, if any.
|
||||
case connect(host: UUID, launchID: String?)
|
||||
|
||||
public static let scheme = "punktfunk"
|
||||
|
||||
/// Build the canonical URL for a route. Non-optional: every route is representable.
|
||||
public var url: URL {
|
||||
switch self {
|
||||
case let .connect(host, launchID):
|
||||
var comps = URLComponents()
|
||||
comps.scheme = Self.scheme
|
||||
comps.host = "connect"
|
||||
comps.path = "/\(host.uuidString)"
|
||||
if let launchID, !launchID.isEmpty {
|
||||
comps.queryItems = [URLQueryItem(name: "launch", value: launchID)]
|
||||
}
|
||||
// URLComponents percent-encodes the query value; force-unwrap is safe for a URL we
|
||||
// fully control (scheme/host/path are all valid).
|
||||
return comps.url!
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an incoming URL, or nil if it isn't a recognized `punktfunk://` route. Tolerant of
|
||||
/// case in the scheme and of a trailing slash on the path.
|
||||
public init?(_ url: URL) {
|
||||
guard url.scheme?.lowercased() == Self.scheme else { return nil }
|
||||
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
switch comps.host?.lowercased() {
|
||||
case "connect":
|
||||
// Path is "/<uuid>"; strip the leading slash and any trailing one.
|
||||
let raw = comps.path
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let host = UUID(uuidString: raw) else { return nil }
|
||||
let launch = comps.queryItems?
|
||||
.first(where: { $0.name == "launch" })?.value
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self = .connect(host: host, launchID: launch)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-3
@@ -1,7 +1,8 @@
|
||||
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
|
||||
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
|
||||
// reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These
|
||||
// live in PunktfunkKit because both the app and the kit's views read them.
|
||||
// live in the dependency-free PunktfunkShared module (re-exported by PunktfunkKit) because the app,
|
||||
// the kit's views, AND the widget extension all read them — the widget needs `DefaultsKey.hosts`.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -27,8 +28,10 @@ public enum DefaultsKey {
|
||||
/// 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.
|
||||
public static let audioChannels = "punktfunk.audioChannels"
|
||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference —
|
||||
/// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`.
|
||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, `"av1"`, or
|
||||
/// `"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 micEnabled = "punktfunk.micEnabled"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
@@ -97,12 +100,28 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
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"
|
||||
/// iOS/iPadOS: keep a streaming session ALIVE when the app is backgrounded (audio background
|
||||
/// mode). Off by default (today's freeze-on-background is the default). When on, backgrounding a
|
||||
/// live session keeps audio playing and the QUIC/pump live while DROPPING video decode, and a
|
||||
/// bounded timer (`backgroundTimeoutMinutes`) auto-disconnects if the user doesn't return. Read
|
||||
/// by ContentView's scenePhase driver. Hidden on tvOS/macOS.
|
||||
public static let backgroundKeepAlive = "punktfunk.backgroundKeepAlive"
|
||||
/// iOS/iPadOS: minutes a backgrounded keep-alive session runs before auto-disconnecting (a
|
||||
/// battery/thermal/bandwidth backstop). Default 10; the UI offers 1/5/10/30. The auto-disconnect
|
||||
/// is non-deliberate (host linger kept), so a late return reconnects fast. Read on enterBackground.
|
||||
public static let backgroundTimeoutMinutes = "punktfunk.backgroundTimeoutMinutes"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
@@ -112,4 +131,15 @@ extension Notification.Name {
|
||||
/// menus) — it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
|
||||
/// discoverable menu-bar surface.
|
||||
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
|
||||
|
||||
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||
/// which runs in the app's process): the app tears the active session down deliberately
|
||||
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||
/// the intent lives in PunktfunkShared and can't reach the app's `SessionModel` directly.
|
||||
public static let punktfunkEndActiveSession = Notification.Name("io.unom.punktfunk.end-active-session")
|
||||
|
||||
/// Posted by the Connect App Intent (Siri/Shortcuts) with a `punktfunk://` URL as `object`:
|
||||
/// the app routes it through the SAME `.onOpenURL` handler a widget tap uses (one router, one
|
||||
/// set of guards). The intent uses `openAppWhenRun`, so the app is foregrounded to receive it.
|
||||
public static let punktfunkOpenDeepLink = Notification.Name("io.unom.punktfunk.open-deep-link")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The saved-host as an App Intents entity — the parameter type for the Connect/Wake intents and
|
||||
// the configurable single-host widget. Lives in the shared module (not the app) because widget
|
||||
// *configuration* intents execute in the EXTENSION process, so the entity can't be app-only.
|
||||
//
|
||||
// AppIntents is genuinely available on macOS (13+), so this is gated on `canImport(AppIntents)`
|
||||
// (unlike ActivityKit, whose macOS types are unavailable) — it compiles on every platform and the
|
||||
// entity query reads the same shared App-Group store the widget does.
|
||||
|
||||
#if canImport(AppIntents)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
public struct HostEntity: AppEntity, Identifiable {
|
||||
public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Host")
|
||||
public static let defaultQuery = HostEntityQuery()
|
||||
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
|
||||
public init(id: UUID, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public init(_ host: StoredHost) {
|
||||
self.id = host.id
|
||||
self.name = host.displayName
|
||||
}
|
||||
|
||||
public var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
}
|
||||
|
||||
public struct HostEntityQuery: EntityQuery {
|
||||
public init() {}
|
||||
|
||||
public func entities(for identifiers: [UUID]) async throws -> [HostEntity] {
|
||||
Self.loadHosts().filter { identifiers.contains($0.id) }.map(HostEntity.init)
|
||||
}
|
||||
|
||||
/// Sorted most-recent first — Siri/Shortcuts and the widget config picker suggest recent hosts.
|
||||
public func suggestedEntities() async throws -> [HostEntity] {
|
||||
Self.loadHosts().map(HostEntity.init)
|
||||
}
|
||||
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
// The Live Activity's attributes — the ONE type that must be identical in the app (which starts
|
||||
// and updates the Activity) and the widget extension (which renders it). Hence it lives in the
|
||||
// dependency-free shared module.
|
||||
//
|
||||
// Gated on `os(iOS)`, NOT `canImport(ActivityKit)`: ActivityKit *imports* on macOS but its types
|
||||
// are `@available(macOS, unavailable)`, so canImport would wrongly admit this on the macOS build.
|
||||
// Live Activities are iPhone/iPad only (iPadOS reports os(iOS)).
|
||||
//
|
||||
// Naming/shape is a runtime contract: an Activity started by one build is decoded by the extension
|
||||
// of the same build, so keep `ContentState` Codable-stable across releases the way `StoredHost` is.
|
||||
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
public struct PunktfunkSessionAttributes: ActivityAttributes {
|
||||
// Static for the Activity's whole life (set at request time).
|
||||
public let hostID: UUID
|
||||
public let hostName: String
|
||||
/// The title of the launched game, if the session started from the library; nil for a plain
|
||||
/// host connect (nothing tracks the live foreground app mid-session).
|
||||
public let launchTitle: String?
|
||||
|
||||
public init(hostID: UUID, hostName: String, launchTitle: String?) {
|
||||
self.hostID = hostID
|
||||
self.hostName = hostName
|
||||
self.launchTitle = launchTitle
|
||||
}
|
||||
|
||||
public struct ContentState: Codable, Hashable {
|
||||
public enum Stage: String, Codable, Hashable {
|
||||
case streaming // foreground, live
|
||||
case background // backgrounded keep-alive (countdown running)
|
||||
case reconnecting // post-loss re-anchor hold
|
||||
case ending // torn down — final state before dismissal
|
||||
}
|
||||
|
||||
public var stage: Stage
|
||||
/// Session start — drives `Text(timerInterval:)` for a free client-side ticking clock (no
|
||||
/// per-second push needed).
|
||||
public var startedAt: Date
|
||||
/// e.g. "2560×1440 @120 · HEVC · HDR". Updated only when it actually changes.
|
||||
public var modeLine: String
|
||||
/// Coarse, updated sparsely (every ~30 s) — never the 1 Hz stats firehose.
|
||||
public var latencyMs: Int?
|
||||
public var mbps: Double?
|
||||
/// While backgrounded: when the keep-alive auto-disconnect fires — drives the countdown.
|
||||
public var backgroundDeadline: Date?
|
||||
|
||||
public init(
|
||||
stage: Stage, startedAt: Date, modeLine: String,
|
||||
latencyMs: Int? = nil, mbps: Double? = nil, backgroundDeadline: Date? = nil
|
||||
) {
|
||||
self.stage = stage
|
||||
self.startedAt = startedAt
|
||||
self.modeLine = modeLine
|
||||
self.latencyMs = latencyMs
|
||||
self.mbps = mbps
|
||||
self.backgroundDeadline = backgroundDeadline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind string for the Live Activity — kept next to the attributes so app + extension agree.
|
||||
public enum PunktfunkActivity {
|
||||
public static let kind = "PunktfunkSession"
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
// App Intents that must compile into BOTH the app and the widget extension live here in the shared
|
||||
// module. Today that's `EndStreamIntent` — the Live Activity's "End stream" button (a
|
||||
// LiveActivityIntent runs in the APP's process) which M4 also surfaces to Siri/Shortcuts.
|
||||
//
|
||||
// Gated on os(iOS): LiveActivityIntent is part of ActivityKit's world (iPhone/iPad only). The M4
|
||||
// Connect/Wake intents that need the app's router live in the app target, not here.
|
||||
|
||||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
/// Ends the active streaming session. Backs the Live Activity's End button and the Shortcuts /
|
||||
/// Siri "End the Punktfunk stream" phrase. `perform()` runs in the app's process (LiveActivityIntent)
|
||||
/// — it posts `.punktfunkEndActiveSession`, which the app's SessionModel owner observes and turns
|
||||
/// into `disconnect(deliberate: true)` (the user explicitly ended it → quit-close the host).
|
||||
@available(iOS 17.0, *)
|
||||
public struct EndStreamIntent: LiveActivityIntent {
|
||||
public static let title: LocalizedStringResource = "End Punktfunk Stream"
|
||||
public static let description = IntentDescription("Ends the active Punktfunk streaming session.")
|
||||
|
||||
public init() {}
|
||||
|
||||
public func perform() async throws -> some IntentResult {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkEndActiveSession, object: nil)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,57 @@
|
||||
// The saved-host model + its on-disk JSON wire format — the widget/extension depends on BOTH, so
|
||||
// they live in the dependency-free shared module. The `ObservableObject` store that wraps them
|
||||
// (`HostStore`, with add/remove/pin/reachability) stays in the app target; discovery-join helpers
|
||||
// (`matches`, `advertises`) stay there too because they reference PunktfunkKit's `DiscoveredHost`.
|
||||
//
|
||||
// Wire-format stability: the JSON encoding of `StoredHost` is now a shared contract between the app
|
||||
// (writer) and the widget (reader). The `PunktfunkSharedTests` codec round-trip pins it — do not
|
||||
// rename the coding keys or make a stored `Optional` non-optional (older saved JSON must still
|
||||
// decode; synthesized Decodable treats a missing Optional as nil).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The management-API port default (distinct from the data-plane `port`). Lives here (not in
|
||||
/// PunktfunkKit's LibraryClient, which re-exports it) so `StoredHost.effectiveMgmtPort` can resolve
|
||||
/// it without the shared module taking a dependency on the kit.
|
||||
public let punktfunkDefaultMgmtPort: UInt16 = 47990
|
||||
|
||||
public struct StoredHost: Identifiable, Codable, Hashable {
|
||||
public var id = UUID()
|
||||
public var name: String
|
||||
public var address: String
|
||||
public var port: UInt16 = 9777
|
||||
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
|
||||
public var pinnedSHA256: Data?
|
||||
/// Last time a streaming session actually started (nil until the first one).
|
||||
public var lastConnected: Date?
|
||||
/// Management-API port for the library browser (distinct from the data-plane `port`). Optional
|
||||
/// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
public var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
public var macAddresses: [String]?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
|
||||
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
|
||||
macAddresses: [String]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.pinnedSHA256 = pinnedSHA256
|
||||
self.lastConnected = lastConnected
|
||||
self.mgmtPort = mgmtPort
|
||||
self.macAddresses = macAddresses
|
||||
}
|
||||
|
||||
public var displayName: String { name.isEmpty ? address : name }
|
||||
public var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
public var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
@@ -237,10 +237,11 @@ final class AV1Tests: XCTestCase {
|
||||
let ready = try XCTUnwrap(frame)
|
||||
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
||||
XCTAssertFalse(ready.isHDR)
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180)
|
||||
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 320)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 180)
|
||||
XCTAssertEqual(
|
||||
CVPixelBufferGetPixelFormatType(ready.pixelBuffer),
|
||||
CVPixelBufferGetPixelFormatType(buffer),
|
||||
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
||||
decoder.reset()
|
||||
}
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user